Both halves came out of playing the StartOS build. The wrapper's health
check and admin actions consume this; they land separately.
The host picks the table size (2-4) when creating a game, and the seats
array is built at that length once. Before, it GREW as people joined, so
the four rows on screen were partly fiction — a 2-player game just started
with a 2-long array, while a host who dropped a bot into a later chair
padded it with a null and silently disabled Start behind a one-line note.
A gap can no longer be written down rather than merely being refused.
That also avoided a trap. Compacting seats at Lobby.Start — the obvious
way to support a "closed" chair — would have shifted the player index that
every PlayerSession stamps at join time and that /api/stream and
/api/intent both route by, handing a player somebody else's railroad with
no error anywhere.
And it fixed a live balance bug: minCombinedRevenue is derived from the
player count, but the config was fixed at CREATE while the count wasn't
known until START, so the lobby guessed 4. Every 2-player game ran against
a floor of 60 instead of 30 — and missing the floor means everyone loses,
so a 2-player competitive game was set up to fail for a UI artifact rather
than a rule.
/api/health gained games:{active,lobby}, read from a new cheap summary()
on GameSession rather than exportSave(), which would copy every intent of
every game to answer a question about none of them. Three admin routes are
new behind an ADMIN_SECRET env var in an x-admin-secret header: GET
/api/games, GET /api/games/<id>/save, DELETE /api/games/<id>. Until now a
started game could not be ended by anyone — no route, no player action, no
resignation — so an abandoned game stayed active in the index and was
faithfully resumed on every boot, forever.
Three deliberate choices there: the admin secret is NOT the join secret,
which every player holds and which would therefore let anyone at the table
destroy anyone else's game; unset means the routes 404 exactly as any
unknown path does, with or without a header, so a server never given an
administrator doesn't advertise that it has one; and a delete returns the
deleted game's save, since the intents are the game (D5) — nothing is
destroyed without being handed to whoever destroyed it.
SavedGame gained an optional lastMoveAt (falling back to createdAt) so
"has this stalled?" survives a restart. Kept out of history for the same
reason the turn timings are: a replay must reproduce a game from decisions
alone, and wall-clock is not a decision.
index.ts logs "Resuming N saved games..." before the loop rather than one
line per game after it. Measured a full 4-player game at 100ms to replay,
and only unfinished games are replayed, so listening before loading would
have bought nothing for the cost of a "still loading" state everywhere.
Verified: 667 tests pass (662 + 5), and the new session tests were checked
against two mutations (lastMoveAt never advancing; resume dropping it) to
confirm they fail without the code. Live against a running server: health
counts tracking through the lobby->game transition, admin auth rejecting a
missing and a wrong secret, list/export/delete, the deleted game's files
and index entry actually gone from disk, a second delete 404ing, the admin
routes invisible when ADMIN_SECRET is unset, and a 3-player table refusing
a 4th player and a size of 5 refused at the door.
Also carries the TODO items raised on 2026-08-21: the lobby offering no
game parameters (the floor bug within it now fixed, the form still
missing), and the four optionalRules — of which only reducedVisibility and
emergencyToolbox are read by anything, while sisterTrains and
employeeRotation are declared, defaulted, and consulted nowhere.
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
v0.4.8 — solitaire is playable in a browser. The whole game runs client-side: the engine is pure,
imports nothing outside itself, and never touches Math.random, so a static host is all it needs.
- Rules — specified, with three open questions left. Thirteen gaps in the original prototype
rules were found and closed; three more came after, and are in
TODO.md: where the Local's coach stands while its engine works (a §A.4 question), Poling — the one card in the deck with no defined behaviour — and whether a Heavy Grade's orientation is rolled or chosen at setup. - 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 and the playable page. A game can be saved, shared, replayed and stepped back through.
- Not built — the multiplayer server (Phases 0 and 1 of the plan are done: seat and player are
separate, turn state is per player, and the page talks to a
Sessionrather than to the engine, so a remote one drops in without the page changing — but there is no server, no turn submission and no per-player push), the 22 opponent-directed cards, and real audio.
Balance is not where it should be: the developer bot averaged 7.0 Revenue against a target of 20 —
of which ~5.4 was the "one Revenue per train that completes its run" rule, so the working freight and
passenger economy is still only ~2. That rule is now a setting that defaults to off, along with
the passenger and freight rates and the opening hand, so the economy can be read on its own and the
alternatives can be played rather than argued about. Nothing in this file or in TODO.md has been
re-measured at the new defaults; TODO.md says why, and says which of it 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
│ └── design/ ← board layout studies and rendering samples
├── public/replays/ ← saved games published to the site's replay directory
├── scripts/ ← build and deploy the static site
├── src/
│ ├── engine/ ← pure rules engine: no I/O, no clock, deterministic from a seed
│ ├── sim/ ← bot, harness, replay, board rendering
│ └── web/ ← the playable site: splash, game, replay viewer
└── test/
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. There is no build step.
npm install
npm test # node --test
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 fourteen of the forty-six event types are never reduced. Anything that needs to rebuild a game replays the intents. - Never call
Math.random(). One ambient random call silently breaks replay. - Track is a deck card, but the opening district is dealt. 96 of the 235 cards are track — the
largest category — 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 now chosen when
the game is dealt: three random cards (the default, and 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. See
TODO.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 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.