- JavaScript 98.9%
- CSS 0.5%
- HTML 0.5%
- Shell 0.1%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| src | ||
| syncity | ||
| tests | ||
| .gitignore | ||
| build.js | ||
| README.md | ||
| release.sh | ||
| run_tests.sh | ||
SYN City
A single-page, mobile-first browser arcade game with a networking/sysadmin
theme. The player is usually a packet, file descriptor, request, or operator
moving through themed infrastructure hazards. No framework, no game engine,
no module system: the whole game runs as one IIFE on a <canvas>, installable
as a PWA.
Repository layout
src/ Editable sources (concatenated into the bundle)
manifest.json Ordered list of source files - the build order
core/ Shared engine code (see per-file headers)
levels/ One file per campaign level
build.js Concatenates src/ into syncity/syn_city.js
syncity/
syn_city.js GENERATED single-file bundle - do not edit by hand
syn_city.min.js Minified deployable (produced by release.sh)
index.html Canvas, HUD, overlay, level picker, PWA metadata
syn_city.css Styling (minified copy alongside)
sw.js Service worker (bump CACHE_VERSION when deploying)
manifest.webmanifest PWA manifest
tests/
syn_city.test.js Headless Node test harness (runs the bundle)
mc_ttl_study.js Monte Carlo TTL balance study
run_tests.sh Builds, syntax-checks, then loops the test harness
release.sh Builds, minifies, rewrites index.html, rsyncs
The build model (read this first)
syncity/syn_city.js is generated. Everything the outside world touches
(the browser, the service worker, release.sh/terser, and the test harness,
which strips the IIFE wrapper off one file and pokes at its internals) expects
a single file with a single shared scope. So the "module system" is
deliberately dumb: build.js concatenates the files listed in
src/manifest.json, in order, inside one (() => { 'use strict'; ... })();
wrapper.
That gives the split two simple rules:
-
Files that only declare functions can go anywhere. Function declarations hoist across the whole IIFE, so every file in
src/levels/and most ofsrc/core/are order-independent. A level's generator can freely call shared helpers "defined later", and vice versa. -
Files with top-level statements are order-sensitive. Four core files run code (or initialize
let/constthat other initializers read) at load time, and must keep their manifest positions:core/dom.js(first) - DOM element lookups.core/levels.js(second) - theLEVELSconst.core/state.js(third) - shared mutable state; some initializers call into core functions (loadGameSettings(),buildLifecycle(),loadBest()).core/main.js(last) - boot: event listener registration, initialresize(),requestAnimationFrame(loop).
Level files may declare their own tuning constants and level-local state (
let fooState = null;) because those initializers are pure literals - nothing reads them until the game is actually running. If you ever need a level-file constant computed from other bindings, put it incore/state.jsinstead, or you risk a temporal-dead-zone crash at load.
build.js refuses to build if src/ contains a .js file that isn't in the
manifest (or vice versa), so forgetting to register a new file fails loudly
rather than silently shipping a stale bundle.
Day-to-day workflow
# edit files under src/, then:
node build.js # regenerate syncity/syn_city.js
bash run_tests.sh # build + syntax check + headless tests (60s loop)
run_tests.sh rebuilds automatically when testing the default bundle, so in
practice "edit, run tests" is enough. Commit both the src/ changes and the
regenerated syncity/syn_city.js (it stays in git: index.html loads it
directly during development, and release.sh minifies it for deploy).
Do not edit syncity/syn_city.js or syncity/syn_city.min.js directly - the
next build will overwrite your changes.
Architecture in one screen
core/levels.jsholds theLEVELSregistry. Each level is a config object (id,axis,unlockKey,buildLifecycle,generate, optionalevents/tick/setup/*Overridehooks, HUD/win/fail copy). The level implementation - the functions those properties point at - lives in that level's file undersrc/levels/.- Standard pad levels reuse the shared path:
buildLifecycle()produces semantic steps,generate*()turns them into pads/hazards/pickups,core/physics.jsruns charge → jump → land,core/hazards.jsruns IDS/martians/firewalls,core/hud.jsandcore/draw.jsrender. - Bespoke levels (balance, grep, pico, tunnel, waf, daily) set
startOverride/updateOverride/drawOverride/hudOverrideand own their whole loop while still using the surrounding overlay, unlock, scoring, persistence and test infrastructure. - Config traits keep shared code level-agnostic. Rather than shared paths
testing
isFoo(), a level declares data or callbacks on its config object and the engine reads them. Examples the core consults by trait, never by level name:padSolid(p),blockLaunch(dir),jumpVelocity(power),scanner: {evade,hit,reason}, a firewall's ownhit: {label,reason},holdGate()(the shared hold-to-fill engine below),launchOverride(power)(a level that owns the whole launch - Next Hop throws the pad it is about to jump onto, then sets the velocities itself), and thedirectionaltrait below. When you find yourself wantingif (isFoo())incore/, add a trait instead (see "Changing shared mechanics"). core/directional.jsowns the pad-tilt input system (edge-tap on horizontal routes, upper/lower press on vertical climbs) for every pad level at once. It names no level: each level customises via an optionaldirectionalconfig trait (falseto opt out; or{ vertical, backward, forwardOnly, upIsForward, midY, hint }, each a value or a function). Kernel opts out; Stack/Buffer setvertical:true; Git, DNS and Mail supply hooks for fork lanes, split-horizon lanes and record recovery.launchDir,supportsDirectionalPads(),usesVerticalPadTilt()andcurrentPadStepDir()live here.- The shared hold-gate engine (
startCharge/releaseCharge/advanceHoldGateincore/physics.js) powers every "park on a pad and hold to fill a meter, exposed to hazards, then something fires" beat: Disaster Recovery (download/import), Split-brain (fence/resync), DNS (zone transfer) and Wardriver (WPA2 crack). Core owns the singleholdActiveflag, thepower/fill-bar update, thegateRatio(gate)helper (also used bydraw.jsfor on-pad meters) and theprogress += dtadvance. A level opts in with aholdGate()trait returningnullor{ pad, gate:{need,progress,done}, onPress?, onHold?, onDone? }— the meter is seconds-based (need), andonDonemay resetgate.done/progressto reject a completion (Split-brain's RESYNC refuses unless the rogue node was fenced first). There is no per-level hold flag, ratio helper or advance loop any more; adding another hold action is just anotherholdGate(). - Multi-phase levels compose from existing hooks, not new core paths.
"Reproduce This" (reproducible builds) plays one route twice with no bespoke
physics:
events(p)stamps a footprint (the landing centre x) on each pad in the BUILD pass;blockWin(p)intercepts the ARTIFACT landing, and on the first (build) pass records the final footprint, flips a level-local phase toreproduce, flings the packet back to pad 0, and returnstrueto veto the win; on the second (reproduce) pass the sameevents/blockWincompare each landing to the stored footprint (within tolerance = reproduced, drift =loseLife) and let the win through. The only core touch is one guardeddrawReproduceFootprints()next to the other per-level world draws indraw.js. Because a plain x-route has no per-hop RNG, the same launch position and power land identically, so the level is genuinely reproducible — the tolerance just forgives human timing (a test asserts Δ=0 on replay). Three fairness devices keep it human-winnable without breaking the theme: footprints pin the launch power and position of every hop (lastLaunchPower/lastLaunchX, set inreleaseCharge), a gold.buildinfoghost ring drawn via thechargeMarker()trait shows the pinned power while charging, thechargeTime()trait slows the charge during the reproduce pass only (-j1; it returns null in the build pass for the core default) to widen the timing window, and each reproduce landing is hermetically normalized onto the next hop's pinned launch spot so aim errors score the current pad but never compound into the next. Drifts stamp a DIFF (every third costs a TTL) and the ARTIFACT judges the whole pass as a ratio, WAF-style: reaching it ends the level (no mid-level teleport) - at least 80% reproduced passes the hash check and wins, below that is a SHA256 mismatch loss whose retry rebuilds from source. The end screen (showEndincore/overlay.js) has its own branch reporting the match ratio and bit-identical count, like the WAF/DNS summaries. - State is shared
lets in one scope (platforms,player,state,lives,streak, ...) declared incore/state.js(shared) or in the owning level's file (level-local). - Persistence is localStorage: unlocks, selection, settings, attempts, best progress, daily results.
Adding a new level
A new level touches a fixed, short list of places. Work through it in order:
-
Create
src/levels/<int>_<id>.js. Define the level's functions there: at minimum a lifecycle builder and a generator for a pad level (buildFooLifecycle(windowSize),generateFoo()), plus whateverdifficultyFoo(i),updateFoo(dt),fooEvents(p, centerDelta), HUD/draw helpers or level-local state (let fooState = null;) it needs. Look at a neighbour of the same archetype first:- horizontal pad route:
tls.js,recovery.js,cron.js - vertical climb:
stack.js,buffer-overflow.js - gates/pickups:
mail.js,git.js - fully bespoke mode:
grep.js,tunnel.js,waf.js
- horizontal pad route:
-
Register the file in
src/manifest.json. Anywhere in the levels block is fine (order there is cosmetic); the build will error until you do. -
Add the config object to
LEVELSinsrc/core/levels.js. Give it a uniqueidandunlockKey(syncity_unlocked_<id>), pointbuildLifecycle/generate/ hooks at your new functions, and write the intro/win/fail/hint copy. Position in the array = position in the campaign; the previous level's win unlocks yours. If the level needs a type predicate (isFoo()used by shared input/physics branches), add it next to the other predicates in the same file. -
Add a level button to
syncity/index.html. The picker buttons arelevelBtn0...levelBtnN; add the next one, and bump theArray.from({ length: N }, ...)count forlevelBtnsinsrc/core/dom.js(the comment there says "keep in sync with LEVELS"). -
Wire bespoke input, only if needed. If the level repurposes controls (its own pointer/key handling rather than hold-and-release), extend the listener branches in
src/core/main.jsthe wayisGrep()/isPico()/isWaf()do, and keep it phone-playable - every level must work with touch. -
Add headless tests in
tests/syn_city.test.js. At minimum: the run generates (pad counts/idx integrity), the win path works, and any novel mechanic has a regression test. If the tests need to reach a new internal (a new state object or helper), add it to the__hooksblock at the top of the harness - that block references internals by name, which is one more reason renaming shared functions should be done deliberately. -
Build and test:
bash run_tests.sh. Also check fairness, not just correctness: procedural routes must stay physically reachable (canReachSegmentand friends incore/generation.jsare there to guard this - use them in your generator).
Then play it on a phone, or at least at a 390x780 viewport.
Changing shared mechanics
Shared code (core/physics.js, core/hazards.js, ...) affects every level.
Prefer adding a level hook (events, tick, blockWin, onWin,
onRespawn, overrides) or a config trait (padSolid, blockLaunch,
jumpVelocity, scanner, directional, holdGate, chargeTime, chargeMarker, launchOverride, ...) over adding if (isFoo())
branches to shared paths - the engine should ask "does this level declare
X?", never "is this level Foo?". That inversion keeps regressions local and
lets levels be added without editing the core. If a shared chain still needs
a new arm, first check whether a trait already covers it (e.g. a hazard can
carry its own hit descriptor instead of a new wallHitInfo case). If you
do change shared behaviour, update the tunables the harness asserts (see the
constants at the top of tests/syn_city.test.js, e.g. TTL
gates) and extend the tests.
Deploying
./release.sh # stage
./release.sh -e prod # production
release.sh rebuilds the bundle, minifies JS/CSS, rewrites index.html to
point at the .min assets, and rsyncs syncity/ (sources under src/ are
outside that directory on purpose, so they never ship). Remember to bump
CACHE_VERSION in syncity/sw.js when deploying changed assets, or PWA
installs will keep serving the old bundle.
Testing notes
The harness stubs DOM/canvas/localStorage/clocks, strips the IIFE wrapper
from the bundle, exposes internals via __hooks, and drives update(dt)
directly. Consequences worth knowing:
- Don't change the
(() => { ... })();wrapper style inbuild.jswithout updating the two regexes in the harness'sloadGame(). run_tests.shloops the suite for 60 seconds because generation is randomized - a single green run can miss rare unfair layouts.?unlock=allon the game URL unlocks all levels for playtesting.
Gameplay has no general score or checksum coins. Objective/resource pickups remain (Wi-Fi credentials, RAM and LVM extents). The secondary control appears only for DUCK in Rotation Station, STOP in Poor Pico, STEER in the VPN tunnel, and WAF blocking. Ducking avoids copytruncate but never hides from scanners.
EHLO validates SPF/DKIM/PTR on pad landings. Git creates changes on edit files and stages/commits them on command pads; neither level requires pickups. Git routes reserve at least 26 pads to fit the branch and action sequence. Revert bounces preserve completed work, and stash boosts stop before action pads.