The first-person answer to Straphanger: everything is walking. The gray S shuttles Times Square ↔ Grand Central at street-platform level; the red 1 runs two levels under Times Square down to 34 St–Penn Station; the green 6 runs under Grand Central down to 33 St. Changing trains is the real ritual: off through the doors, into a stairwell, down to the hallway, past the black-and-white directional signs, down a second flight, and onto a platform where the next train is — or just was. The station layouts are impressionistic, not surveys; the shape of a transfer is the point.
Every train is a pure function of the wall clock (dwell → close → kinematic run → open, per line, with phase offsets so the three trains stay out of sync), so the timetable can’t drift, the whole game is headlessly testable, and a train you can hear rumbling below really is where the sound says it is.
What it actually simulates
- The world is a zone graph: platforms, train interiors, stairwell ramps, and hallways, each with its own collision. Doors are geometry — the platform edge is solid except where an open door stands, and the aperture’s width is the door-open fraction, so closing doors narrow and push you out.
- Stairwells are volumes, not teleports. Walk into the mouth and your eye-height rides the ramp down; the well’s sides are solid, and entry is gated on velocity into the flight, so brushing past a stairwell on the platform doesn’t swallow you and stepping off one doesn’t suck you back.
- Riding is being carried — the train’s frame delta is added to yours while you’re inside; gangways let you walk the consist at speed. Announcements, the door chime, and speed-and-proximity rumble come from the same timeline the collision uses.
Reuse
Only scene.js knows three.js exists (injected as a parameter); the sim is
framework-free and proven by test/run.test.mjs, whose scripted walker makes
the complete two-transfer journey — Penn Station → red 1 → stairs → hallway →
stairs → S → Grand Central → stairs → hallway → stairs → green 6 → 33 St —
through the same input API the keyboard uses.
geometry.js— the world as data: car/door/gangway dimensions, the threeLINES(axis, cross offset, depth, stations, phase), stairwells and hallways, and the line-local ↔ world coordinate maps.shuttle.js—lineState(lineIdx, t): position, speed, door fraction, dwell countdown. Pure timeline, no stepping.player.js— the zone machine: oneupdatePlayer(p, dt, input, t)that resolves look, walk, boarding funnels, gangways, stair ramps, hallway walls, and the riding frame-attach. No DOM, no three.scene.js— a build → bake → freeze pipeline: the world is described as quads with base colors plus plain-data lamp positions, light is baked into vertex colors once at startup, and everything static merges into a few dozen unlit meshes per region (station, tunnel run, hallway+stairs). One station builder, one train builder, one tunnel builder serve all three lines via per-line group transforms; trains split into a constant-brightness interior mesh and an exterior whose material brightness eases down in tunnels, with all door leaves merged into two sliding meshes per train. Floors are slabs with real holes cut where flights descend.audio.js— synthesized rumble/chime + speech announcements.
Gotchas
- Vendored three.js is two files (
three.module.min.jsimportsthree.core.min.jssince r167). Ship both. - The player integrates on the wall clock in fixed ≤1/60 s substeps, so a throttled tab can’t walk in slow motion while the trains keep schedule.
- Stair mouths sit on zone boundaries. Wall clamps hold the player a radius short of the mouth plane, so entry can’t require crossing it — it triggers in a band at the lip, gated on inward velocity. The same gate is the reason exiting a flight doesn’t instantly recapture you.
- There are no runtime lights, on purpose. The first expanded build used
real PointLights (~100 → trimmed to ~44) and still crawled: forward-rendered
three.js evaluates every scene light for every fragment of every lit
material. The bake rewrite took the same world from ~3,500 draw calls to
~25–40 and from 44-light fragments to unlit vertex colors — measured 7–11×
faster even under CPU rasterization, and trivial for any real GPU. Press
Pin the demo for the live fps/draw-call readout; rendering is 1× by default (?hdin the URL opts into device-pixel-ratio rendering). If the world someday outgrows this (streamed districts, crowds, physics), that — not raw scene size — is the moment to consider a real engine. demo/carries its own copy of every module (ADR-0002):cp src/*.js demo/after touchingsrc/.- Where this goes next (designed-for, not built): more lines are data entries plus stair/hallway wiring; crowds and straphanger’s full-network data both have obvious seams to slot into.




