A mobile browser game about bouncing a packet across SYN City: open TCP connections, complete TLS handshakes, establish Ethernet links, reach onion services, climb the stack, restore from backups, queue SMTP mail, load-balance backend services, survive kernel I/O, enter Stealth under scans, dodge hazards, and close cleanly with FIN.
  • JavaScript 98.9%
  • CSS 0.5%
  • HTML 0.5%
  • Shell 0.1%
Find a file
Miguel Jacq 6c9182806c
All checks were successful
SYN City tests / test (push) Successful in 1m53s
Fix left fork pad: level 10
2026-09-28 15:06:55 +10:00
.forgejo/workflows Add CI workflow 2026-07-03 20:54:13 +10:00
src Fix left fork pad: level 10 2026-09-28 15:06:55 +10:00
syncity Fix left fork pad: level 10 2026-09-28 15:06:55 +10:00
tests Fix left fork pad: level 10 2026-09-28 15:06:55 +10:00
.gitignore Ignore minified files 2026-07-04 14:11:08 +10:00
build.js Modularise the code into individual files 2026-07-08 16:19:59 +10:00
README.md Tweaks to Git and SMTP levels - removing pickups 2026-09-28 13:47:11 +10:00
release.sh Modularise the code into individual files 2026-07-08 16:19:59 +10:00
run_tests.sh Modularise the code into individual files 2026-07-08 16:19:59 +10:00

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:

  1. Files that only declare functions can go anywhere. Function declarations hoist across the whole IIFE, so every file in src/levels/ and most of src/core/ are order-independent. A level's generator can freely call shared helpers "defined later", and vice versa.

  2. Files with top-level statements are order-sensitive. Four core files run code (or initialize let/const that other initializers read) at load time, and must keep their manifest positions:

    • core/dom.js (first) - DOM element lookups.
    • core/levels.js (second) - the LEVELS const.
    • core/state.js (third) - shared mutable state; some initializers call into core functions (loadGameSettings(), buildLifecycle(), loadBest()).
    • core/main.js (last) - boot: event listener registration, initial resize(), 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 in core/state.js instead, 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.js holds the LEVELS registry. Each level is a config object (id, axis, unlockKey, buildLifecycle, generate, optional events/tick/setup/*Override hooks, HUD/win/fail copy). The level implementation - the functions those properties point at - lives in that level's file under src/levels/.
  • Standard pad levels reuse the shared path: buildLifecycle() produces semantic steps, generate*() turns them into pads/hazards/pickups, core/physics.js runs charge → jump → land, core/hazards.js runs IDS/martians/firewalls, core/hud.js and core/draw.js render.
  • Bespoke levels (balance, grep, pico, tunnel, waf, daily) set startOverride/updateOverride/drawOverride/hudOverride and 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 own hit: {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 the directional trait below. When you find yourself wanting if (isFoo()) in core/, add a trait instead (see "Changing shared mechanics").
  • core/directional.js owns 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 optional directional config trait (false to opt out; or { vertical, backward, forwardOnly, upIsForward, midY, hint }, each a value or a function). Kernel opts out; Stack/Buffer set vertical:true; Git, DNS and Mail supply hooks for fork lanes, split-horizon lanes and record recovery. launchDir, supportsDirectionalPads(), usesVerticalPadTilt() and currentPadStepDir() live here.
  • The shared hold-gate engine (startCharge/releaseCharge/ advanceHoldGate in core/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 single holdActive flag, the power/fill-bar update, the gateRatio(gate) helper (also used by draw.js for on-pad meters) and the progress += dt advance. A level opts in with a holdGate() trait returning null or { pad, gate:{need,progress,done}, onPress?, onHold?, onDone? } — the meter is seconds-based (need), and onDone may reset gate.done/progress to 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 another holdGate().
  • 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 to reproduce, flings the packet back to pad 0, and returns true to veto the win; on the second (reproduce) pass the same events/blockWin compare each landing to the stored footprint (within tolerance = reproduced, drift = loseLife) and let the win through. The only core touch is one guarded drawReproduceFootprints() next to the other per-level world draws in draw.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 in releaseCharge), a gold .buildinfo ghost ring drawn via the chargeMarker() trait shows the pinned power while charging, the chargeTime() 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 (showEnd in core/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 in core/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:

  1. 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 whatever difficultyFoo(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
  2. 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.

  3. Add the config object to LEVELS in src/core/levels.js. Give it a unique id and unlockKey (syncity_unlocked_<id>), point buildLifecycle / 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.

  4. Add a level button to syncity/index.html. The picker buttons are levelBtn0 ... levelBtnN; add the next one, and bump the Array.from({ length: N }, ...) count for levelBtns in src/core/dom.js (the comment there says "keep in sync with LEVELS").

  5. 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.js the way isGrep()/isPico()/ isWaf() do, and keep it phone-playable - every level must work with touch.

  6. 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 __hooks block at the top of the harness - that block references internals by name, which is one more reason renaming shared functions should be done deliberately.

  7. Build and test: bash run_tests.sh. Also check fairness, not just correctness: procedural routes must stay physically reachable (canReachSegment and friends in core/generation.js are 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 in build.js without updating the two regexes in the harness's loadGame().
  • run_tests.sh loops the suite for 60 seconds because generation is randomized - a single green run can miss rare unfair layouts.
  • ?unlock=all on 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.