16 KiB
Game State Model
The entity model for one game of Station Master, derived from
../rules/rules-v0.2.md. This is the rules engine's world — pure data,
no I/O.
Written as pseudo-structure. Field names are illustrative; the shapes and constraints are the point.
1. The game
Game
id
config : GameConfig
seed : integer -- all RNG derives from this; games are replayable
players : Player[] -- index order IS seating order, west → east
division : Division
decks : Decks
yards : Yards
timetable : (TrainCardId | null)[12] -- index 0 = Stage 1
clock : Clock
collisionsToday : integer -- §3.4, resets at the start of each Day
status : setup | active | finished
outcome : Outcome | null
GameConfig
mode : solitaire | competitive | coop
victory : firstToTarget | highestAfterDays
length : short | standard | campaign
optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox }
Targets and Day counts derive from length (§3.2): short 15/3, standard 30/5, campaign 60/10. Co-op
multiplies the target by player count. Do not store the derived numbers — compute them, so a config
change cannot leave a stale target behind.
Clock
day : integer, from 1
stage : 1..12 -- the Pocket Watch
phase : localOps | newTrain | mainline | loadUnload | shiftChange
currentActor : PlayerIndex | null
pendingDecision : SuperintendentClearance | null -- §8.1, during mainline only
superintendent : PlayerIndex
currentActor is the whole turn machine. After Gap 1, exactly one player may act at any moment.
Every other client is spectating. Deriving this rather than storing it is tempting but wrong — the
acting order starts at the Superintendent and proceeds left, and that traversal needs an explicit
cursor.
The Mainline Phase is automatic but not input-free. §8.1's fourth condition hands the Superintendent a judgment call: when a train would depart into a Subdivision already occupied by a train moving the same direction, he decides whether it is safe to follow. Movement otherwise proceeds with no input.
SuperintendentClearance
train : CrewTrayId -- the train awaiting clearance
followingInto: SubdivisionRef
occupiedBy : CrewTrayId -- the train ahead, moving the same direction
This is the game's central tension and must be modelled, not optimised away. Gap 2 made the consequences automatic — collisions resolve with no die roll — precisely so that this decision carries full weight. The Superintendent who clears a following train into an occupied Subdivision owns the wreck (§10), and in Competitive mode three wrecks in a Day ends the game for everyone (§3.4).
Note the asymmetry with §8.1's third condition: a train in the next Subdivision moving towards the considered train is an absolute bar — no decision, the train simply does not depart. Only the same-direction case is discretionary.
Shift change fires at Stages 3, 6, 9 and 12 (§5), passing the Fedora one seat left.
2. The Division
The Division is an ordered west-to-east sequence of nodes. For N players there are N Office Areas and N+1 Mainline cards (§4.3), with a Division Point beyond each end.
Division
nodes : DivisionNode[] -- ordered west → east
DivisionNode
= DivisionPoint { side: west | east, holding: CrewTrayId[] }
| MainlineCard { regions: [Region, Region] } -- 2 regions (§2.1, Gap 4b)
| OfficeArea { ...see §3 }
Region
occupant : CrewTrayId | null
A concrete 3-player Division:
DP(W) ML Office₀ ML Office₁ ML Office₂ ML DP(E)
└── 2 regions each ──┘
Subdivisions are derived, never stored
A Subdivision is the Mainline track between the Limits of opposing Control Points (§2.1). Whistle Posts are not Control Points and sit inside a Subdivision like ordinary mainline.
subdivisions(division) =
split the node sequence at every Office whose type is a Control Point,
and at both Division Points
At game start every Office is a Whistle Post, so the entire railroad is one Subdivision (§8) — which is why early traffic is so constrained. Each Office upgrade splits a Subdivision in two.
Recompute this on demand. Caching it means every Office upgrade must remember to invalidate the cache, and forgetting is a silent, hard-to-find bug in highball legality.
3. The Office Area
A grid of track cards, not a row (Gap 4a). The Running Track is the horizontal row through the Office; everything else is Secondary Track.
OfficeArea
owner : PlayerIndex
officeType : whistlePost | depot | station | terminal
grid : Map<GridCoord, TrackCard> -- sparse; cards are placed during play
officeCoord : GridCoord -- where the Office card sits
runningRow : integer -- the grid row that is the Running Track
limitsWest : GridCoord -- moves outward as the Running Track grows
limitsEast : GridCoord
adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(officeType)
adTrackCount: whistlePost 1 | depot 2 | station 3 | terminal 4 -- §11.1
isControlPoint = officeType != whistlePost
isPassengerFacility = officeType != whistlePost
Office cards are geometrically interchangeable (§11.3). All four tiers carry the same track
footprint — a through track plus a plain junction stub above and below — and differ only in the
three properties above. An upgrade therefore changes officeType and nothing else; it must never
touch grid, connections, or anything attached to the Office card. The junction stubs carry no
directional restriction: §A.1's turnout rule governs drawn turnout cards only.
TrackCard
geometry : straight | turnout | runAround | officeCard | facilityCard
isOperationalRail : boolean -- the wheel icon; turnouts are false (§A.1)
connections : Direction[] -- which edges have rails
facility : Facility | null -- facility cards carry their own track (Gap 4a)
standing : RollingStock[] -- uncoupled cars left here, in track order
Constraints that are easy to lose
These are the ones that will be got wrong if they are not written down explicitly:
- A turnout's two legs are not joined to each other. Entering from the stem (§A.1's "A") permits
either leg; entering from a leg permits only the stem. This is an absent edge, not a one-way
edge — A→B and B→A are both legal. Modelling it as a directed graph forbids legal moves. The
card's internal connections are
stem-throughandstem-diverge, andthrough-divergesimply does not exist. Separately, a traversal may never leave a card by the port it entered through; that is what "without changing direction" means in practice. - A turnout is not Operational Rail. A train may pass through but not stop (§A.1).
- A Move never changes direction. One Move is travel from one Operational Rail card to another, any distance, no reversal (§2.4, §A). Reversing costs a second Move.
- A Facility track locked by loads stops being Operational Rail. While any load sits on
MEN | AT | WORK, no car may be picked up or dropped off there and no train may occupy or move on it (§9.3). This is dynamic —isOperationalRailfor a facility card is a function of facility state, not a constant. - Coupling is mandatory during Local Ops. Moving a Crew Tray into standing cars picks them up; you may not go around them (§A.4).
- Coupling is fatal during the Mainline Phase. The same encounter is a collision (§A.4, §8.3).
- The Office track accepts no drop-offs despite being Operational Rail (§A.4).
- Two trains may not share a card or pass through each other — except the Office track, which admits as many trains as it has free A/D tracks (§A.4).
- An Office upgrade must preserve every connection (§11.3). All four tiers share identical
geometry precisely so the upgrade is a property change, not a card swap. An implementation that
models the upgrade as "remove old card, place new card" will silently orphan any Secondary Track
hanging off the Office — mutate
officeTypein place instead. - The Office card's stubs are not turnouts. Do not route them through the §A.1 directional logic; a train may pass between the Running Track and either Secondary row freely.
4. Trains and Rolling Stock
CrewTray
id
trainCard : TrainCardId | null -- null while switching a local crew
engineFront : boolean -- which end the engine occupies (§A.3)
consist : RollingStock[] -- ORDERED, left-to-right, max 4
direction : east | west
position : NodeRef -- division point, mainline region, or grid coord
movesUsed : integer -- within the current Local Ops Phase
RollingStock
type : coach | boxcar | reefer | hopper | tank | caboose
loaded : boolean -- coloured = loaded, white = empty (§2.2)
Consist order is load-bearing and must never be modelled as a set. Cars come off in the order they are seated in the tray (§A.3), and §8.1 blocks a highball unless the consist matches the order listed on the train card. An unordered collection makes both rules unimplementable.
The four-slot limit includes the caboose (§A.4, Gap 4c). consist.length ≤ 4, cabooses counted.
A train requiring a caboose therefore carries at most three revenue cars — the single most likely
off-by-one in the whole model.
TrainCard
number : 1..12
isExtra : boolean
direction : east | west | playerChoice -- Extras are head-on; the player picks (§2.3)
consistSpec : { count, allowedTypes[], requiresCaboose }
class : limited | mailExpress | manifest | coalDrag | oilTrain | wayFreight | extra
Mainline movement order is sort by (number, isExtra) ascending — Timetabled before Extra on a tie
(§8, Gap 5).
Train make-up is a loop, not a single pass (§7, Gap 9). Cycle Superintendent-then-left, one car
per player per pass, until either the consist reaches consistSpec.count or no suitable car remains
in the Division Yard. A single pass would make consist length a function of player count. The loop
also resolves the 5+ player case for free: it ends the moment the consist fills, mid-pass if need be,
leaving no player holding a car they cannot place.
5. Facilities
Facility
kind : passenger | freight
subtype : mineTipple | produceShed | grocersWarehouse | oilRefinery | powerPlant | office
allows : { outbound: boolean, inbound: boolean } -- some freight are one-way (§9)
outboundBox : RollingStock[] -- green, to-be-loaded
inboundBox : RollingStock[] -- red, just-unloaded
capacity : { outbound, inbound, combined? } -- combined when the number touches both boxes (§9.1)
menAtWork : [Load|null, Load|null, Load|null] -- freight only; one load per box
industryTrack : { length: integer, cars: RollingStock[] } -- 3 or 4 per card (§12.5); ≤4 (§9.3)
laborers : integer
porters : integer
usedThisStage : { laborers: integer, porters: integer } -- resets each Stage (§9.1)
modifiers : ModifierRef[] -- adjacent cards raising capacity, track length or workers
All of these are populated from ../rules/card-reference.md, which is
the authoritative per-card catalogue.
industryTrack is where cars are spotted for loading and unloading, and it is the field that
stops being Operational Rail while menAtWork holds any load (constraint 4 below). Its length is
3 or 4 depending on the facility and may be raised by a Team Track modifier.
A Modifier serves only one Facility per Stage even when adjacent to two (§9). Track which one claimed it this Stage.
A freight load costs four Laborer-actions for one Revenue point — Green → MEN → AT → WORK → onto the car (§9.3). A Porter earns a point in one action. This asymmetry is deliberate; do not "fix" it.
Neither is the binding constraint, though. Each player gets exactly one Local Operations action
per Stage, and stocking a green box or clearing a red one consumes the whole of it (§6.3). That
one-action-per-Stage budget is what actually limits Revenue — roughly one point per action, ceiling
12 per Day. Worker counts mostly determine how often a facility idles. See
../rules/card-reference.md for the full model; it is
what §3's targets are calibrated against.
6. Decks and Yards
Decks
homeOffice : CardId[] -- face down, order is secret
departments : [CardId|null, CardId|null, CardId|null] -- face-up market slots (§2.6)
salvageYard : CardId[] -- face up
hands : Map<PlayerIndex, CardId[]> -- private, max 3 (4 with Red Flag)
redFlags : Map<PlayerIndex, boolean> -- separate supply; removed when played
The Departments are slots fed from the one deck, not decks with their own contents (§2.6, Gap 4a). Reshuffle sweeps the Salvage Yard and all three Department slots back into the Home Office deck (§6.2).
Yards
divisionYard : RollingStock[] -- the supply pile
classificationYard: RollingStock[] -- used stock
When the Division Yard empties, everything in the Classification Yard moves back to it (§2.2). Used engines and cabooses return directly to the Division Yard, skipping the Classification Yard.
7. Players and scoring
Player
index : integer -- seating position, fixed unless Employee Rotation is on
name
revenue : integer -- may go negative; collisions cost 5 (§10)
officeArea : OfficeAreaRef
Revenue sources are exactly two: +1 per completed load/unload operation (§9.2, §9.3) and −5 per collision charged to that player (§10). There are no others.
Outcome
result : win | loss
winner : PlayerIndex | null -- null for co-op, solitaire, and shared losses
reason : targetReached | daysElapsed | collisionFloor | revenueFloor
Termination checks, in the order they must be evaluated:
- Collision floor (Competitive only) —
collisionsToday >= 3ends the game immediately, all players lose (§3.4). Checked the moment a collision resolves, not at end of Stage. - Target reached (firstToTarget) — checked whenever Revenue increases.
- Days elapsed (highestAfterDays) — at the end of the final Day, apply the collective Revenue
floor
3 × players × Daysfor Competitive (§3.5), or the mode target for Solitaire and Co-op (§3.3). Below the floor, everyone loses regardless of score.
8. What is derived, not stored
Getting this list wrong produces stale-state bugs that are miserable to find:
| Derived | From |
|---|---|
| Subdivisions | Office types along the Division |
| Whether a train may highball | Subdivision occupancy + consist order + A/D availability (§8.1) — except the same-direction case, which requires the Superintendent's decision |
| Whether a collision occurs | Board state at the moment of movement (§8.3) — pure, no RNG |
| Legal Moves for a Crew Tray | Grid connectivity, turnout direction, Operational Rail, occupancy |
| Facility track's Operational Rail status | Whether menAtWork holds any load |
| Revenue targets and floors | config.length, config.mode, player count |
| Whose turn it is | clock.currentActor — the one exception; explicitly stored (§1) |
9. Notes for whoever implements this
- Seed everything. One seed per game, all shuffles and D12 rolls drawn from it. This makes a reported bug reproducible, which for a rules engine this size is worth more than it costs.
- Model the grid sparsely. Office Areas start at three cards and grow unevenly; a fixed 2-D array will either waste space or need resizing at exactly the wrong moment.
- Validate consist order on every mutation, not just at highball time. A consist that silently reorders during a switching move produces a train that can never legally depart, with no clue as to when it broke.
- Treat
isOperationalRailas a function, not a field, for facility cards — see constraint 4.