# 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.3 — 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 `Session` rather 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 averages 7.0 Revenue against a target of 20 — of which ~5.4 is the "one Revenue per train that clears your section" rule, so the working freight and passenger economy is still only ~2. `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`](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`](docs/design.md) — it indexes everything. Commit messages stay high level; [`CHANGELOG.md`](CHANGELOG.md) carries the reasoning and the measurements, and [`TODO.md`](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. ```sh 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 ```sh 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, and `advance(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[] }` and `fromSave` replays 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 one exception is setup: track is shuffled separately and each player is dealt **3 track + 3 other**, with the leftover track shuffled back in afterwards. You therefore open holding six against a limit of three, and the first turn is spent choosing. Provisional — see `TODO.md`. - **Clearing the line scores.** Every train highballed out of your Office earns one Revenue, paid once. It is worth +5.4 a game, more than the entire freight and passenger economy put together, and it pays for traffic you do not have to work. Also provisional. - **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. `checkTurnoutUpgrade` in `src/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()` in `src/engine/state.ts`. - **A Passenger Facility handles no freight, structurally.** `menAtWork` is `null` on 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 is `joins()` in `src/engine/track.ts`, never a bare pair of `hasPort()` 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 is `nw_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.