Two stations are drawn from the whole system — all 25 weekday services, 432 station complexes, real transfers — and the clock starts. You are one rider: down the stairs, through the crowd, onto the right train, between the cars, off at the cross-platform, up the stairs, through the passageway, and out the green-globe exit at the far end. Time is scored in simulated minutes against a router-computed par, so a run reads like a commute (“made it in 27:40”) while playing in about five real minutes.
The daily run seeds the trip and the entire timetable from the date, so
everyone gets the same A→B and the same trains — comparable times with no
server. Best times live in localStorage.
What it actually simulates
- The network is the real one. Every weekday service with its true stop sequence — expresses skip what they skip, the A forks at Rockaway Blvd, all three shuttles run, and free transfers (the 14 St passage, Lex/59↔63, Times Sq↔Port Authority…) are walkable passageways with real time costs.
- Trains are pure functions of the clock. A train is
(route, direction, departure time); where it is now is computed, never stepped. The whole timetable is deterministic from the seed — that’s what makes daily runs fair, headless tests exact, and a future online mode cheap (ghost riders need only the seed and an input stream). - Stations are procedural but honest. Platform levels are inferred from track geometry: routes share a level iff their neighbor stops through the station overlap. That regenerates W 4 St’s stacked A-C-E over B-D-F-M and Times Sq’s four levels from data, with no per-station authoring. Stairs interleave across levels on the mezzanine so every level is reachable.
- Crowds are drag, not walls. Local density bleeds walk speed; Space shoves through (counted, judged); Shift runs on a stamina budget you’ll want for the transfer stairs. Hubs are thicker, and every arriving train dumps a counterflow at the doors.
- Boarding is physical. Doors are places; opposite-direction trains berth offset so their door zones never overlap; originating terminals hold doors open; the last three seconds are the conductor’s (“stand clear of the closing doors” — spoken aloud if sound is on). Board the wrong train and nothing saves you but the strip map.
Reuse
Seven framework-free ES modules in src/; the sim has no DOM and
test/run.test.mjs proves it — network invariants, router sanity, and a
scripted bot that plays complete trips through the same input API the keyboard
uses.
network.js— the subway as data: route stop-sequences (compact strings of station ids), explicit display names for every id, transfer edges, hub weights, anchor coordinates + interpolation.validateNetwork()treats the name table as a typo net: any misspelled id in a route fails loudly.router.js— Dijkstra over (station, boarded-route) states with boarding/transfer penalties;pickTrip()draws seeded A→B pairs with par in a playable band, biased toward transfers. Also home ofmulberry32.world.js— the analytic timetable:arrivals(station, clock)is the countdown clock,trainById(id, clock)answers “where is it now”.station.js— procedural station interiors (levels, stairs, exits, passageways) from the union-find grouping described above.sim.js— the rider state machine: walk/stairs/portal/board/ride/alight, crowds, stamina, scoring.update(dt, {left,right,up,down,run,shove}).view.js— canvas renderer for the three scenes (station cutaway, car interior with live strip map, full-system map with hint glow and every train moving) plus signage primitives (bullets, mosaic bands).audio.js— synthesized chime/rumble + speech-synthesis announcements.
Gotchas
- Weekday daytime only, slightly idealized. Z is folded into J, the 5 runs its full rush pattern to Flatbush, the B to Bedford Park, peak-only express variants are ignored, SIR is out of scope. Headways are compressed (3–9 game minutes) so waiting stays fun; time scale is 6 game-seconds per real second.
- All platforms are islands. Side-platform stations (“wrong side” mistakes) were cut for scene readability — direction disambiguation comes from the berth offset instead. Fidelity note, not an accident.
- Coordinates are schematic. Anchors + per-route interpolation, with x stretched ~8× on the map because the real system is geographically a noodle. Ride times derive from these coordinates (clamped per segment), so Brooklyn locals run a touch fast. Par comes from the same numbers, so scoring stays fair.
- The
Ais two services. Branching routes don’t fit a linear stop list, so Lefferts and Far Rockaway are separate route entries sharing one bullet — anything iterating “routes” must expect both (routesAt()returns ids, not bullets). - Boarding intent is keyed, not guessed. ↓ is doors, ↑ is stairs/exits. An early build had ↑ do both and the player kept re-boarding the train they’d just left whenever a stair sat near a door. If you add interactions, keep the verbs separate.
demo/carries its own copy of every module (ADR-0002). Touchsrc/and re-copy:cp src/*.js demo/.- v2 (online multiplayer) is designed-for, not built. Deferred by choice:
everything a netcode layer needs is already true — seeded determinism, plain
per-tick input commands, analytic train positions. Racing ghosts = same seed
- replayed input streams; live racing = a relay for those streams. The shell
exposes
window.__debug(sim, input, newGame) which is how the media bot and any future spectator already drive it.
- replayed input streams; live racing = a relay for those streams. The shell
exposes



