You are in the cab of a ten-car local, uptown from South Ferry, and you have the whole job: drive the train, stop it on the conductor’s board, key the doors, make the announcement, and hold the timetable — without running a red. Six stops, one canvas, no engine.
The view out of the window is a pseudo-3D tunnel drawn entirely with 2D canvas calls. Everything in the world lives on a single axis: the line is one array of feet, and stations, signals, restrictions and the train ahead of you are all just positions on it. Perspective is the only place a second and third dimension appear, and only at draw time.
What it actually simulates
- The handle. Nine positions from full parallel down through coast to full service and emergency. Tractive effort falls away with speed, the brakes take about a second to build up, and an emergency application will not release until the train has been standing for six seconds — so grabbing for it costs you the schedule, exactly like it does on the road.
- The signals. Fixed blocks, automatic aspects computed from occupancy — red for the block ahead, yellow for the one after that — and a trip arm on every red that dumps you into emergency whether you meant to stop or not. The train ahead is a real train running its own stop pattern, so the aspects you get are the aspects it leaves behind.
- Timing sections.
GTandSTsignals stay red until the approaching train is actually under the posted speed. A marker board warns you well before the lamp, because by the time you can read the lamp it is too late. - The station stop. Berthing accuracy against the board, a door interlock that cuts power until the doors are closed and locked, a minimum dwell you can cut short if you are behind (and leave people on the platform), and an announcement that is scored if you skip it.
- The card. A timetable derived from the railroad itself — every leg walked at 96% of what is posted, plus the seconds each timing section and each stop really costs. The cab shows a live countdown to each stop’s due time, so the incentive is exactly the real one: run at the limit, never over it. End of the line gets you a report: berth, schedule, call and score per stop, then safety and ride quality across the run.
- Dispatch. The radio breaks in with a squawk, and it reacts to what you actually do: a hold order that pins you at a platform (mandatory — rolling out from under one goes in the report, though the card moves with the order), an escalating pair of speed warnings once you’ve spent enough seconds over the posted limit, an automatic report every time a trip arm catches you, a call asking what the holdup is if you sit on open doors, and a “where are you?” when you fall 90 seconds down. Run through a station and the tower orders a hold-for-instructions at the next one — on your time, not the card’s; run through the terminal and the trip is over on the spot. Close the doors before the minimum dwell and you leave people on the platform.
- Scenarios. Each run is seeded: a rider catches the closing doors at one
station (they bounce back open — re-close), dispatch holds you at another,
and the train ahead dwells differently every trip, so some runs are all
greens and some are a crawl behind a slow leader.
?seed=Non the demo URL replays one exact run. - The cab shouts. Flashing banners over the glass for the states that matter — OVERSPEED with a repeating beeper, SIGNAL AT STOP when a red is inside braking distance and you’re not braking, BRAKE FOR <stop> when the board is closer than your stopping distance, HOLD while dispatch has you. A dwell bar under the stop panel fills to the minimum before it goes green.
Reuse
Five framework-free ES modules in src/. The sim core has no DOM in it at all —
test/run.test.mjs drives an entire run headless in Node.
line.js — the route as data. Stations with a berth mark and a platform
side, posted limits, timers, and curves in 1/ft. Editing this array is
how you build a different line; everything else reads it:
export const DEFAULT_LINE = {
name: 'K Local · Uptown', length: 14400, maxSpeed: 40,
stations: [{ name: 'Canal St', mark: 3400, side: 'right', note: 'Transfer: J N Q R W' }, /* … */],
limits: [{ from: -200, to: 1150, mph: 15, label: 'CURVE' }],
timers: [{ pos: 4600, mph: 20, label: 'GT' }],
curves: [{ from: 150, to: 1150, k: 0.00075 }],
};
train.js — createTrain() / updateTrain(train, dt), plus moveHandle,
toggleDoors, applyEmergency and stoppingDistance(). Pure state math in feet
and seconds; the door interlock and the emergency latch live here, not in the game.
signals.js — createSignalSystem(line) returns refresh(player, others)
to recompute every aspect from block occupancy, and crossed(from, to) for the
signals a train’s front passed during a step, each carrying the aspect it was
showing at that moment. That pairing is the whole trip-arm mechanic.
run.js — the orchestrator: ticks the train, drives the leader, runs the
station state machine, runs the seeded scenario engine (door obstruction,
dispatch hold, dwell and lateness nags), scores it. update(dt, commands)
takes an array of plain strings ('handle-up', 'doors', 'announce', …),
and guidance() returns everything a cab display wants in one object —
including the ranked alerts list — so the HUD never reaches into the sim’s
internals. createRun({ seed }) makes any run replayable.
view.js — createView(canvas, { line }).draw(state). The projection is
three lines: a point x feet right of the centreline, y feet up, d feet
ahead lands at focal/d from the vanishing point. Curves are integrated ahead
of the camera into a lateral offset per distance, which is why the tunnel bends
without the world ever leaving one dimension.
audio.js — synthesised cab sound: a traction whine that tracks speed,
rolling noise under it, and the door chime, horn and brake-pipe hiss. No assets.
Gotchas
- Draw order is the depth buffer. The shell is painted far band to near band
and everything else goes on top of it. Anything new has to be inserted at the
right point in
draw()or it will hover in front of the wall it belongs behind. - World-anchored detail is what sells speed. Camera-relative geometry (the
tunnel shell) is static no matter how fast you go; the ties, lamps, columns and
wall seams are pinned to positions on the line, and they are the only reason
the view moves. Delete
drawWallDetailand the train appears to stop. - The timing sections need a marker board. The signal that enforces a timer
is only reachable once you are already inside its block, so
guidance()reports the next timer separately from the next signal. Without that lookahead the timer is unclearable and the sim is unfair — which is what the first headless run proved. demo/carries its own copy of every module (self-contained by contract, ADR-0002). Touchsrc/and re-copy:cp src/*.js demo/.- The timetable is computed, not written down, so adding a station or changing a restriction re-derives the whole card automatically. A dispatch hold shifts the card for the rest of the run — dispatch’s time is not charged to you.
- Alert logic lives in
guidance(), not in the HUD: the demo only decides how to flash, never whether something is alarming. Anticipatory warnings are gated on “not already braking”, or they nag through every normal stop.




