Jesse.Markowitz 7804756f11 v0.6.2 — an Extra starts where you put it, a train card is never discarded
Three more from the v0.4.9e gameplay-testing round, filed as Gitea issues, plus two bugs found
underneath them. Gitea#2 is diagnosed but NOT fixed: it needs a ruling, and the reasoning is in
TODO.md under Play Balance.

GITEA#4 — AN EXTRA STARTS WHERE THE PLAYER PUTS IT. Only one Division Point was ever offered,
chosen by number parity. The number no longer decides an Extra's direction — the start does, which
supersedes the recorded ruling that "the number decides, like everything else on the timetable".
The two cannot both hold: an odd, westbound Extra placed at the WEST end would leave the Division
on its first move having crossed nothing, and be paid for the run. Either end now runs the train
away from itself; at an Interchange or a Control Point the player picks the direction. The
Interchange start is a YARD, off the running line, which is what makes the Superintendent clause
work: placing it can never force a collision, a guaranteed one holds it there for another Stage,
and a potential one is the Superintendent's to rule on — exactly evaluateClearance's `blocked` and
`ask`, so nothing new decides collisions. Where an Extra may start is now a house rule
(divisionPointsOnly / ownOffice / anyOffice, defaulting to what the engine already did). The
legacy `atSeat` intent field still replays as it always meant.

FOUND UNDERNEATH IT: an Extra started away from a Division Point ran empty. isBeingMadeUp tested
position alone, so the Control Point start has been shipping since it was added with a train that
could never be given a consist. Found by playing it, not by the tests, which had only asserted
where the tray landed.

FOUND UNDERNEATH IT: collide left the wrecks on the card. Destroyed trains kept their Transit
entries, and evaluateClearance counts every transit as an occupant, so one rear-end collision
permanently poisoned that Mainline card for every later train.

THE MAINLINE CARDS WERE ROLLED, NOT DEALT — drawn from the nine types with replacement, so a
Division could hold two Interchanges and Plains carried the weight of a card printed once. "An
Extra may start at the Interchange if one is on the board" only reads as a rule if the board holds
at most one. Now dealt from the printed deck without replacement, verified over 1600 deals. This
re-deals every seed: the published replays were re-recorded, and the saved games in docs/ are
retired too — two of those were already dead before this release and nobody had noticed.

GITEA#6 — A TRAIN CARD IS NEVER DISCARDED, Timetabled and Extra alike. The forced play needed no
mechanism: nothing discardable plus a hand over the limit leaves exactly one legal way to end the
turn, and playing a train is unconditionally legal, so the corner cannot trap anyone. The bot
needed no rule either. 400/400 games finished, revenue unmoved, trains scheduled 1.2 -> 1.3. The
player is told on the card and on the button.

GITEA#7 — COACH COUNTS. 1/2 Crack Limited 3 -> 2, 5/6 The Sparrow 2 -> 3. A change to the cards,
so Trains3.pdf and the transcription keep the original numbers with a footnote while content.ts
and the Home Deck reference carry what the game plays.

CONTENT.TS COMMENT PASS — no data changed, only comments. Four were factually wrong, including an
office table naming counts doubled long ago and a pointer to a DEALT_DECK_SIZE that has never
existed. Every Enhancement row cited its implementation by line number and every citation had
rotted; they name functions now. Card counts came out of the comments, since they move with play
balance; source-sheet figures and dated measurements stayed.

TODO.md gains an item for a card reference generated from content.ts, in six sections, so the
documentation cannot disagree with the game.

715 tests pass, tsc clean, site builds.
2026-08-22 19:51:40 -04:00
2026-07-31 07:19:57 -04:00

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 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 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, 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 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 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.origin in src/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. 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.
S
Description
station master train game
Readme
12 MiB
Languages
TypeScript 97%
HTML 3%