Files
station-master/README.md
T

6.0 KiB
Raw Blame History

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.1.0 — 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 — fully specified. Ten gaps in the original prototype rules found and resolved.
  • Card faces — every card's printed values specified.
  • Architecture — six documents, including a 20-component build 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 — multiplayer (the engine runs 2–5 player games and the bot plays them, but there is no server, no turn submission and no per-player view), the 22 opponent-directed cards, and real audio.

Balance is not where it should be: the developer bot averages 1.4 Revenue against a target of 20. 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, and advance(state) for everything the game does on its own — Mainline movement, collisions, the Stage clock, Superintendent rotation.
  • State is fold(events). The event log is the source of truth, which is what gives reconnection, restart recovery and post-game replay from a single decision.
  • Never call Math.random(). One ambient random call silently breaks replay.
  • Track is a deck card. 104 of the 243 cards in the Home Office deck 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. There is no separate supply and no one-piece-a-turn cap.
  • 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.