Jesse.MarkowitzandClaude Sonnet 5 a70b7f88f3 Say who the game is waiting on, and move the Fedora; replay in words
Three items folded into the unshipped v0.7.9.

WAITING ON (reported by Jesse from play). The status line said "nobody —
the Division is running itself" while the game was stopped on the
Superintendent. Frame.actor carried clock.currentActor, which is null
for the whole Mainline Phase, so all three interruptions — §8.1's
clearance ruling, Gitea#5's Yard Office offer, Gitea#19's Red Flag
prompt — reported that nobody was holding it up. actingPlayer had the
answer since the Gitea#5 refactor; the Frame threw it away. It carries
actingPlayer now, plus a new `awaiting` field naming the question and
the train: "waiting on Bob · a clearance ruling · Train 4". Naming the
person alone is not enough when three different things can be pending.

THE FEDORA (TODO #29) rides at the right-hand end of the phase row
instead of a line of its own, and wraps under rather than squeezing the
chips.

THE DEVELOPER REPLAY (TODO #34) printed "loss — revenueFloor", the same
defect Gitea#16 was filed about, still alive because nothing
player-facing pointed at it. panels.ts's reasonSentence is exported and
shared rather than reimplemented, fed the last recorded frame and
stripped of markup. The drift test maps win/loss to won/lost so it still
checks the two AGREE rather than that they are spelled alike.

Also carries the previous, unsigned commit's work: the two setup screens
worded the same section by section.

877 tests pass, ten new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
2026-08-30 09:01:19 -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

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 under test/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 in TODO.md under Multiplayer — chiefly that a player cannot see what the others did, and that a lost session token still locks someone out of a running game from a genuinely fresh browser.
  • 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 means about zero.

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
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 roughly a third of the event types are never reduced at all. Anything that needs to rebuild a game replays the intents.
  • 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. pendingDecision is a discriminated union and decisionActor is 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: returning needsClearance unwinds 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 awaitingExtension rather than finished: the table is asked whether to play one more Day, unanimously, and asked again at the end of every Day it grants. state.official is written at the first ending and never rewritten, so the winner is always the one decided at config.days however long play carries on — config.days itself never moves, and state.extraDays counts 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.tally counts 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, applyIntent and advance. It is not hooked in reduce, 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 the Frame, so a multiplayer client gets the same numbers as solitaire from one implementation. What it cannot count is anything the events do not say. trainStoodStill fires once per game for the X18 Circus alone, so "the longest an engine sat on a siding" has no signal behind it — see TODO.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. 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%