A second-digit bump, deliberately. 0.8.1 had been reserved for the seatless display table; that work is getting more thought, and this table pass over v0.8.0.17 earned the number on its own. Six reports: one was a rules question, one a wording complaint with a real bug underneath, four straightforward. A CAR CLEARED FROM A RED INBOUND BOX CAME BACK STILL LOADED. Reported as wording — "technically accurate but doesn't make any sense" — and the wording was the visible half. `inboundCleared` pushed `pooled(e.stock)` under a comment reading "a car back in a yard is back in the common supply, carrying nothing", and `pooled` does not do that: it strips the load's origin stamp and keeps `loaded` ON PURPOSE, because a train can retire at a Division Point with freight aboard. So the comment described an intention the call never carried out, and every car the Freight Agent cleared reached the Classification Yard carrying a load already delivered and already paid for. It bites hardest on coaches: `passengersDetrained` takes `coach && !loaded` out of the Division Yard and §2.2 refills that yard from Classification, so a cleared coach came back as stock that could never unload another passenger. Measured over five three-Day solitaire games: 18 loaded coaches in Classification against 6 empty. The red box is where a journey ENDS; clearing it sends the passengers out of the station, or the delivered load into the industry, and returns the CAR, empty. The option says that now instead of describing the counter that moves. SCOPED TO THE RED BOX ON PURPOSE. `retireTrain` also returns loaded cars and is left alone: that is what `pooled`'s own documentation describes, and a loaded car in a yard is pre-loaded cargo rather than dead stock — it can be made up and delivered, and a loaded coach can still detrain. Only the red box's contents had already finished their journey. GAMES IN PROGRESS DO RESUME, MEASURED RATHER THAN ARGUED. Yard contents change, so the worry was real. All twelve saves on the test server were pulled and replayed through `tryResumeSession` — the server's own boot check — against this build. Six resume, six refuse, and the six refusals are the SAME six, at the same moves, with the same codes, that 0.8.0.17 already logged. Nothing new was stranded. That pre-install replay is a better check than reading the next boot log, because it answers before the install rather than after. THE HISTORY SAID "Mainline card 7" and left the reader to remember what card 7 was — with two Plains dealt, which is why the slot is kept beside the name rather than replaced by it. A second fault sat one word to its left and nobody reported it: the name was built as `e.key.replace(/([A-Z])/g, ' $1')`, so `absSignals` printed as "abs Signals" while the action list directly above said "ABS Signals". `narrate` takes `mainlineAt` and `enhancementName` beside the `facilityAt` it already had, and `simpleCardName` is exported so the log reads the same table the buttons do. `mainlineModified` had both faults and is fixed with it. LEAVING A RUNNING GAME WAS A DEAD END. `enterSeating` hides the choice section and only the lobby's own two leave paths put it back; leaving a running game is a third route, so the lobby came back holding nothing but "Games you are in" with both doors on the page at display:none and no control able to reveal them. Reset in `runLobby`, which is the one function every route onto that screen goes through — which is exactly why the two paths that did it themselves missed a third. THE LOBBY'S ACTION BUTTONS CARRY THE BOARD'S AMBER. A list of the actions rather than `#lobby button`: the settings form under Create is a field of inputs, and amber on all of it would say everything is a move and so say nothing. A disabled Start game drops back to chrome. THE DEPARTMENT REFILL IS A RULE AND IS NOW WRITTEN DOWN. §6.2 — "if any of the Department decks is empty, draw a Home Office card and place it in the empty spot" — firing only when the draw actually empties the pile. Kept as implemented (Jesse's ruling) and stated in rules.md and home-deck.md, neither of which had ever mentioned it. A rule implemented from the prototype and never written down is a rule that surprises the table. 1016 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
Station Master
A railroad operations game set in the era of timetable-and-train-order railroading (1840–1950), being built as a multiplayer browser game with an authoritative server.
You are the Station Master of a lineside Office on a shared east–west Division. Trains run to a timetable with no radios — just pocket watches and written orders. You switch cars, work freight and passengers, and take your turn as Superintendent deciding whether it is safe to clear a following train into an occupied Subdivision. Get that wrong and two trains meet at speed.
Status
Solitaire is playable in a browser and multiplayer runs against a server. The solitaire game runs
entirely client-side — the engine is pure, imports nothing outside itself, and never touches
Math.random, so a static host is all it needs. Multiplayer adds an authoritative server for the
seats a shared game requires.
The current version is in package.json, and every page stamps it with the commit
and build date, so what is deployed can always be identified from the page itself. This section
deliberately no longer names one: it went stale for six releases.
-
Rules — specified. Thirteen gaps in the original prototype rules were found and closed, and the three that came after were all settled in v0.5.0: a coach may be set out at the Office (§A.4), Poling stays out of the deck at zero copies since the source records its effect only as "TBD", and a Heavy Grade's orientation is rolled from the seed, never chosen by a player — see
docs/rules/implications.md§10 for each ruling and its reasoning. -
Card faces — every card's printed values specified.
-
Architecture — seven documents, including a 20-component build plan and the multiplayer plan.
-
Code — the engine, the bot, the balance harness, the replay viewer, the playable page, and the server. A game can be saved, shared, replayed and stepped back through.
-
Multiplayer — built and running.
src/server/serves a lobby (create, join, preview, leave, add a bot, start, and a stream), turn submission, per-session state and persistence, with its own tests undertest/server/. Games survive a release rather than being destroyed by one. A player weighing a join reads the whole rule set before taking a seat; a seat survives a browser reload; anybody may leave and the host may clear a chair; and the four transient signals that make a game feel alive — sound, the timetable flash, an announcement, the badge on the card you just drew — reach a remote client, which they did not before v0.7.0. What is still open is inTODO.mdunder Multiplayer — chiefly that a lost session token still locks someone out of a running game from a genuinely fresh browser. -
Watching the table — v0.8.0. Every accepted move, and every automatic phase that does anything, becomes an ordered presentation step: the board replays other people's turns instead of arriving already rearranged. This is what closes "a player cannot see what the others did", which stood open through v0.7.x. A bot's whole switching turn used to land in one push, because
driveBots()plays it out before the push goes back; now it arrives as a run of steps, the district panel follows whoever is acting, and a[N behind] … [Skip]row says how far the board is from the game. Dwell is assigned by kind — a switching move holds the screen, turn bookkeeping costs nothing — and is tunable per viewer without a rebuild. Solitaire runs the same path, which is where its automatic phases finally get a visible beat.The caption and the history panel are not the same list, since 2026-09-17. Switching is logged in full; a move from the middle of a turn writes its line as tone
trace, so the step still carries it — the board captions the move and earns its dwell, anddwellForSteppays nothing for a step that said nothing — while the history panel filters the tone out. What the panel draws is the line saying somebody switched, the FIRST move, work at an industry, the Small Yard sort, and a closing summary. The last move rides in that summary rather than being kept in place: nothing knows a move was the last until the turn is over, by which time the line has been written and streamed to every client, so it cannot be revised. Not yet checked in a browser: the mechanism is proven server-side against a live SSE stream and the page is proven not to throw, but nobody has watched a bot switch on screen. -
Not built — the opponent-directed cards (the Action and Space-use categories, held out of every deck until they have an implementation, along with the defensive cards whose only purpose is to answer them), and real audio. No screen offers a control for the opponent cards any more: the toggle could not do anything, so both screens state the fact in words instead.
Balance is not where it should be, and this file no longer quotes a figure for it. It used to say "the developer bot averages 7.0 Revenue against a target of 20", which stopped being true the moment the transit rule it names was defaulted to off — that rule was worth ~5.4 of the 7.0, for traffic nobody had to work. Measured at the current defaults the bot meant about zero until it began planning its switching turns (2026-09-14), which put it near 2.8.
The three rates — passenger per coach, freight per load, train per transit — are settings fixed when
the game is dealt, along with the opening hand and where an Extra may start, so the economy can be
read on its own and the alternatives played rather than argued about. Run
node src/sim/harness.ts 400 standard for today's number rather than trusting one written here;
TODO.md says which of the gap is the bot and which is the deck.
Versions follow the convention at the top of CHANGELOG.md: third digit for fixes,
second for a set of features, 1.0 for the first release that deserves the name.
Layout
station-master/
├── CHANGELOG.md ← what changed and why, in detail, commit to commit
├── TODO.md ← open questions, provisional numbers, things to come back to
├── docs/
│ ├── rules/ ← the ruleset, card reference, glossary, decision record
│ ├── architecture/ ← how it is built, and what the pieces are
│ ├── plans/ ← worked plans for a single change, kept for the reasoning
│ └── design/ ← board layout studies and rendering samples
├── public/
│ ├── replays/ ← saved games published to the site's replay directory
│ └── images/ ← art the build copies into the site
├── scripts/ ← build and deploy the static site
├── src/
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
│ ├── server/ ← the authoritative multiplayer server: lobby, sessions, persistence
│ ├── sim/ ← bot, harness, replay, board rendering
│ └── web/ ← the playable site: splash, game, lobby, replay viewer
└── test/ ← including test/server/ for the server's own suite
Start with docs/design.md — it indexes everything. Commit messages stay high
level; CHANGELOG.md carries the reasoning and the measurements, and
TODO.md is what we have decided not to forget.
Development
Requires Node 22.18+, which runs TypeScript directly by type stripping — so there is no compile
step, and the engine, the bot and the tests all run straight from source. (npm run build:web is a
separate thing: it assembles the static SITE into dist/.)
npm install
npm test # node --test, everything except the bot simulations — run after every change
npm run test:sim # test/sim.test.ts, the bot simulations (~7 min) — run after a bot or balance change
npm run typecheck # tsc --noEmit
Because Node strips types rather than compiling them, the codebase is restricted to erasable
syntax: no enum, no parameter properties, no namespaces. tsconfig.json enforces this.
Measuring the bot
node src/sim/harness.ts 200 # how the bot does, with the funnel
node src/sim/compare.ts 1600 trainCapSlack=1 # one change, paired against the current bot
Never judge a heuristic on an unpaired run. Revenue has σ ≈ 9 across games, so two runs of the
identical bot differ by about a point through nothing but the deal. compare.ts gives both
policies the same seed and reports the per-seed difference, where σ is 5.3 — 1600 seeds puts the
standard error at ±0.13, in under two minutes. Keep a change at t ≥ 3, and read the
better/worse/identical split beside the mean: a gain carried by a few rescued games is a different
claim from one spread across the field.
Variants come from makeDeveloperBot(tweaks). A tweak is temporary — when it measures well it
becomes the default and the flag is deleted in the same commit; when it measures badly it is deleted
with the finding recorded in CHANGELOG.md. A bot that accumulates switches nobody can account for
is the thing this machinery exists to prevent.
Design notes worth knowing
- The rules engine is pure. No I/O, no clock, no sockets, and all randomness derives from one stored seed — so any game is exactly replayable, and a full game can be driven in a unit test with no server at all.
- It has two entry points, not one.
apply(state, intent)for player actions, andadvance(state)for everything the game does on its own — Mainline movement, collisions, the Stage clock, Superintendent rotation. - The canonical record is the seed plus the intents. A game is
{ seed, history: Intent[] }andfromSavereplays it exactly — that one property gives save, share, undo, restart recovery and post-game replay. Events are a DERIVED stream: they narrate what happened and drive the display, and they do not reconstruct the position. The phase driver mutates state and then describes it, so roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game replays the intents. - A save is only guaranteed to replay on the version that wrote it. This is the cost of the property above and is not a bug to be fixed case by case: a save is a list of moves, so it reopens by being re-played through the current rules. Any rules change that makes a once-legal move illegal will stop an older save at that move — a change to the deck is the likeliest breaker, since a history that names a card the deck no longer deals has no legal answer at all, but any narrowing of what is permitted does it. It fails safe in every case: the load is declined, the offending move is named, and the file is left untouched, so nothing a player has is destroyed. Assume an older save may not open, tell players so wherever a build is announced, and read "this save will not load" in a bug report as this before treating it as a fault. Versioned, migratable replays are a post-1.0 question — deliberately not worth the effort while the rules are still moving this fast, since every migration would have to be written against rules that changed again next release.
- Never call
Math.random(). One ambient random call silently breaks replay. - The Mainline Phase can stop and ask, and there are three questions it asks. §8.1's clearance
ruling goes to the Superintendent; the Yard Office offer and the Red Flag prompt go to the owner of
the district a train is arriving at.
pendingDecisionis a discriminated union anddecisionActoris the single place that maps a question to whoever must answer it — a new question adds a case there and nowhere else. Ask before the move is committed: returningneedsClearanceunwinds the whole phase and the driver re-enters from the top, so anything already mutated is applied twice or left half-done. - A game ends by PAUSING, and the first ending is the real one. Running out of Days, or closing
short of the combined Revenue floor, puts the game in
awaitingExtensionrather thanfinished: the table is asked whether to play one more Day, unanimously, and asked again at the end of every Day it grants.state.officialis written at the first ending and never rewritten, so the winner is always the one decided atconfig.dayshowever long play carries on —config.daysitself never moves, andstate.extraDayscounts the borrowed ones. A §3.4 collision breach is the exception and finishes outright, during an extended Day exactly as during the scheduled game. Because a save is a replay, the vote is an intent (game.extend), and it is the one intent that carries its own player: every seat may vote in any order, so a replay cannot derive who did. - Statistics are folded, not recorded.
state.tallycounts what the event stream says happened — trains through the Division and how many of them did any switching, loads made up and broken — and is hooked at the two boundaries every event crosses exactly once,applyIntentandadvance. It is not hooked inreduce, which never sees the phase driver's events at all. Nothing in the rules reads it, so adding a counter is always safe; it rides theFrame, so a multiplayer client gets the same numbers as solitaire from one implementation. What it cannot count is anything the events do not say.trainStoodStillfires once per game for the X18 Circus alone, so "the longest an engine sat on a siding" has no signal behind it — seeTODO.md#36 rather than assuming an event means what its name suggests. - A game is one of four TYPES, and a type is a set of defaults rather than a ruleset. Co-op,
Competitive, Cutthroat and Solitaire (
src/web/presets.ts) each name an opening hand, an Extra rule, three revenue rates and the victory conditions; picking one fills the form, and changing any of them selects Custom, which keeps the scoring of the type it came from. The type is derived from the numbers rather than stored, so a saved game carries no name that can disagree with what it actually is. Both screens that deal a game — the lobby and the New Game dialog — ask the same eleven questions through one shared block (src/web/settings-form.ts), because for two releases they each had a question the other lacked. What separates the types: Co-op alone pays for a transit, Cutthroat alone lets an Extra be planted in another player's district and has no shared failure condition at all beyond three collisions in a Day, and the Revenue floor is a formula in the table size and the length (3 per player per Day in Co-op, 2 in Competitive) rather than a number. - Track is a deck card, but the opening district is dealt. Track is the largest category in the
deck by a distance — so a district is built from what you draw, and building it costs you the
industry or train you drew instead. The opening hand is the exception, and it is chosen when the
game is dealt: three random cards (the prototype rule), six random cards, or three track and
three other from two separately shuffled piles. The last of those is the only one that guarantees
you a district to build; deal six and you open over the limit of three, so the first turn is spent
choosing. Every game type now deals six (Jesse’s call, v0.7.0) — the engine's own fallback,
SOLO_CONFIG, deliberately did not move with it, because every sim measurement is taken against that. SeeTODO.md. - What the work pays is a setting too. Passenger revenue per coach, freight revenue per load and train revenue per transit each run 0–5 and are fixed when the game is dealt. The first two default to 1 and pay at both ends of a movement — boarding and detraining, loading and unloading. The third pays every player when a train runs off the end of the Division and defaults to 0: at 1 it was worth more than the entire freight and passenger economy put together, for traffic nobody has to work. A seed alone therefore no longer names a game — the settings ride in the URL beside it, and every save records the rules it was dealt under.
- A load may not be broken in the district that made it. Freight or passengers loaded anywhere in
an Office Area cannot be unloaded anywhere in that same Office Area — not at another facility, not
in a later Stage. A train has to carry them to a different district first. The printed game turns
the chip upside down in the tray; here the load carries the seat that made it (
RollingStock.origininsrc/engine/state.ts) and it never expires. Without it a Freight House could unload the boxcar its own Laborers had just loaded and a platform could detrain the passengers it had just boarded, each paying Revenue at both ends for a load that went nowhere: worth 0.60 ± 0.10 Revenue a game to the developer bot over 400 paired deals, on 78 of them. - A turnout can be laid on top of a card already down. It upgrades a straight at any rotation, or
a curve whose arc matches its own diverging leg — both strict port supersets of what they replace,
so an upgrade can never sever an existing join. Without it a district could only hang off track that
happened to be a turnout when it went down. Blocked by a standing car or a built Enhancement; the
replaced card leaves play, as board cards always do.
checkTurnoutUpgradeinsrc/engine/apply.ts. - A Subdivision is the unit of clearance, not a card. §8.1 asks whether the next Subdivision
holds an opposing train (an absolute bar) or a following one (the Superintendent's call). Every
Office starts as a Whistle Post, which is not a Control Point, so the whole railroad begins as ONE
Subdivision — that is why early traffic is so constrained, and why upgrading an Office to a Control
Point splits one in two and buys capacity.
subdivisions()insrc/engine/state.ts. - A Passenger Facility handles no freight, structurally.
menAtWorkisnullon a Depot, Station or Terminal rather than an unused array, so freight work is refused on the shape of the facility and no renderer can draw a pipeline that cannot exist. The converse holds: industries carry no porters. - The track is 45° geometry, not a graph on a grid. Measured off
docs/tracks.png: the through rail runs east–west across the exact vertical middle of every card, there is no north–south track anywhere, and everything that leaves through the north or south edge does so at 45°, through the middle of that edge. So two ports meeting is not enough to make a rail — two 45° legs can meet at the same point and still form a V. Adjacency isjoins()insrc/engine/track.ts, never a bare pair ofhasPort()calls. A printed card turns 180° but never flips, so its handedness fixes which diagonal its leg lies on for good, and a run-around needs one card of each hand. Left isnw_se— named from the points, the side a train entering a turnout sees its diverging route leave toward, with curves following the turnout whose leg they continue. Both tables had it inverted until playtesting caught it; the geometry was never wrong, only the words.