The first-person answer to Straphanger: no verbs, no keys beyond walking. You stand on the Times Square shuttle platform; the S pulls in, stops, opens its doors, holds them fourteen seconds, chimes, and leaves — with or without you. Boarding is walking through the open door. Riding is standing inside while the world moves. Alighting at Grand Central is walking out. The two-station scope is not a cut-down subway — the 42 St Shuttle really is a two-station line, so the whole loop is honest.
Everything the previous prototype learned about determinism carries over: the shuttle is a pure function of the wall clock (dwell → close → run → open, kinematically integrated), so the schedule cannot drift, the sim is headlessly testable, and the train will genuinely leave without you.
What it actually simulates
- Doors are geometry, not UI. The platform edge is a solid wall except where an open door stands; the aperture’s width is the door opening fraction, so a closing door physically narrows until you no longer fit and then pushes you out of the gap. Missing the train means watching it go.
- Riding is being carried. While you’re inside the car volume, the train’s frame delta is added to yours. Walk car to car through the gangways at 35 mph if you like; the tunnel lights strobe past the windows and the camera picks up a small sway and rumble jitter scaled by speed.
- The run is kinematic: accelerate at 1.25 m/s² to 16 m/s, cruise, brake — ~36 s between terminals, dwell 14 s, chime at door-close. The HUD countdown comes straight from the same timeline.
Reuse
src/ is split so that only one module knows three.js exists:
geometry.js— the world as constants: car/door/gangway dimensions, platform cross-section, both stations,doorWorldXs(trainX).shuttle.js—shuttleState(t)→{x, v, doorOpen, at, phase, tToDepart}. Pure timeline, no stepping.player.js—updatePlayer(p, dt, input, trainState): yaw/pitch look, velocity smoothing, and all collision (edge, back wall, car walls, gangway pass-throughs, door apertures, the riding frame-attach). No DOM, no three.scene.js—createScene(THREE, canvas); THREE is injected so the module doesn’t care where the vendored build lives. Procedural canvas textures for the tiled walls, mosaics and signage; ACES tone mapping;sync(state, player, dt)moves the train, slides the door leaves, and places the camera.audio.js— synthesized rumble (speed- and proximity-gated), the two-tone chime, speech-synthesis announcements.test/run.test.mjs— shuttle invariants (doors never open while moving) plus a scripted walker that boards, rides, alights, stays aboard, and proves walls are walls — all through the input API.
Gotchas
- Vendored three.js is two files. Since r167 the min build is split:
three.module.min.jsimportsthree.core.min.js. Ship both or the module resolves nothing (this cost one confused 404). - The player integrates on the wall clock, not the frame. The train runs on real time, so the shell steps the player in fixed ≤1/60 s substeps to stay time-correct under a throttled rAF — otherwise a background tab walks in slow motion while the train keeps schedule.
scene.jsimports nothing. It takes THREE as a parameter; the sim modules stay framework-free and Node-testable. Keep it that way when adding visuals.demo/carries its own copy of every module (ADR-0002):cp src/*.js demo/after touchingsrc/. The demo opens standalone (demo: tab) — pointer lock inside a sandboxed iframe is browser-dependent, a tab is not.- Where this goes next (by design, not yet built): more of the network via
straphanger’s
network.js, stairs and mezzanines as actual geometry, other riders, and the same seeded determinism it inherits means ghost riders stay cheap.



