10 KiB
Protocol
The client↔server message vocabulary, and how per-player views are redacted. Written against
../rules/rules-v0.2.md and game-state.md.
Three message families, matching the flows in overview.md:
- Intent — client → server. A proposal. May be rejected.
- Event — server → clients. A fact. Ordered, and the game's history.
- View — server → one client. A redacted state snapshot.
Message names below are illustrative. What matters is the set of decisions a player can make, which is fixed by the rules.
1. Intents
Every intent carries gameId, playerId, and a seq the server uses to reject duplicates from a
reconnecting client.
1.1 Local Operations Phase
The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that Stage.
LocalOps.Choose { option: switch | draw | freightAgent }
If switch — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility
during night Stages (Appendix B).
Switch.Move { trayId, to: GridCoord }
Switch.DropCars { trayId, count } -- from the tray end; order is fixed (§A.3)
Switch.SetDirection{ trayId, direction } -- costs a Move; a Move never reverses (§2.4)
Switch.End { } -- forfeit remaining Moves
Pick-up is not an intent. Moving into standing cars couples them automatically and mandatorily
(§A.4) — the server does it as part of resolving Switch.Move. Offering a choice would be a rules
violation.
Switch.DropCars takes a count, not a set of car ids: cars come off in seated order (§A.3), so the
only free choice is how many.
If draw (§6.2):
Draw.FromHomeOffice { }
Draw.FromDepartment { slot: 0|1|2 }
Card.Play { cardId, placement? } -- placement required for track/facility/office cards
Card.Discard { cardId, toSlot: 0|1|2 }
Draw.End { } -- server rejects while hand > 3
placement is a GridCoord for track, Facility and Office cards (§11.2); absent for train cards,
which go to the timetable or to an Extra's staging.
If freightAgent — exactly one of three operations (§6.3):
FreightAgent.StockToOutbound { facilityId, stockType, loaded }
FreightAgent.InboundToClass { facilityId, stockIndex }
FreightAgent.UnjamToClass { facilityId, from: outbound|inbound|menAtWork, index }
1.2 New Train Phase
Per train being made up, each player adds one car in Superintendent-then-left order (§7):
NewTrain.PlaceCar { trainId, stockType, loaded }
NewTrain.PassCar { trainId } -- only legal when no suitable car exists in the Division Yard
This cycles (§7, Gap 9). The server keeps going round the table — one car per player per pass — until the consist is full or no suitable car remains in the Division Yard. It is not a single pass, so the client must not assume one prompt per player per train. At five or more players the round ends mid-pass when the consist fills; players not yet reached simply are not prompted.
PassCar must be validated, not trusted: §7 requires the player to "make every effort to find a
suitable car." The server checks the Division Yard against the train's consistSpec and rejects a
pass when a legal car is available. A pass by every player in a full pass is the loop's other
termination condition.
For a played Extra (§7, Gap 4c) the owning player chooses its whole consist:
Extra.Launch { trainCardId, at: westDP | eastDP, direction }
Extra.LoadConsist { trainCardId, stock: [{stockType, loaded}] } -- ≤3 cars + caboose
1.3 Mainline Phase
The phase is automatic except for the Superintendent's clearance decision (§8.1, fourth
condition). The server pauses movement, sets clock.pendingDecision, and waits:
Mainline.Clearance { trainId, allow: boolean }
Only the current Superintendent may send this, and only for the train named in the pending decision. This intent arrives out of the normal turn order and may concern another player's train — the one place in the game where a player acts during someone else's traffic.
The Red Flag card (Emergency Toolbox, Appendix B) also interrupts here:
RedFlag.Play { } -- averts an imminent collision; card is removed from the game
1.4 Load/Unload Phase
Each Laborer and Porter is usable once per Stage (§9.1). Resolve one at a time.
Porter.Board { facilityId } -- §9.2, +1 Revenue
Porter.Detrain { facilityId } -- §9.2, +1 Revenue
Laborer.AdvanceLoad { facilityId, loadRef } -- Green → MEN → AT → WORK → car (§9.3)
Laborer.BeginUnload { facilityId, carIndex } -- first step of unloading
LoadUnload.End { }
A freight load needs four Laborer.AdvanceLoad intents to score one point; a Porter scores in one
(§9.3). The client should show this clearly — it is the single most surprising thing about the game's
economy for a new player.
1.5 Lobby
Covered in lobby-and-sessions.md; listed here for completeness.
Lobby.Create { config }
Lobby.Join { gameCode, displayName }
Lobby.Leave { }
Lobby.SetConfig { config } -- host only, before start
Lobby.Start { } -- host only
2. Rejections
An intent is rejected with a reason a client can render, not a stack trace:
Rejected { seq, code, message, ... }
codes: NOT_YOUR_TURN | WRONG_PHASE | OPTION_ALREADY_CHOSEN | NO_MOVES_REMAINING
ILLEGAL_MOVE | TRACK_OCCUPIED | NOT_OPERATIONAL_RAIL | WOULD_REVERSE
CONSIST_FULL | CONSIST_ORDER | HAND_LIMIT | CARD_NOT_IN_HAND
NO_PLACEMENT | NOT_CONNECTED | RESOURCE_SPENT | SUITABLE_CAR_EXISTS
NOT_SUPERINTENDENT | NO_PENDING_DECISION
The client may pre-validate to grey out illegal moves — good UX — but the server's answer is the only
one that counts (overview.md). Rejections should be rare in a well-built client and are therefore
worth logging server-side: a spike usually means client and server rules have drifted.
3. Events
Events are the authoritative history. State is fold(events), which is what gives reconnection,
persistence and replay for one design decision.
Phase and clock
StageBegan { day, stage }
PhaseBegan { phase }
ActorChanged { playerIndex | null }
SuperintendentChanged { playerIndex }
DayEnded { day, revenues }
Local operations
LocalOpsOptionChosen { playerIndex, option }
TrayMoved { trayId, from, to, movesRemaining }
CarsCoupled { trayId, stock[] } -- automatic, from a Move
CarsDropped { trayId, at, stock[] }
CardDrawn { playerIndex, source, cardId? } -- cardId omitted for other players
CardPlayed { playerIndex, cardId, placement? }
OfficeUpgraded { playerIndex, from, to } -- property change; connections unaffected (§11.3)
CardDiscarded { playerIndex, cardId, toSlot }
DeckReshuffled { }
Trains
TrainScheduled { trainCardId, timetableSlot } -- from the 1D12 roll (§7)
TrainMadeUp { trainCardId, trayId, at }
CarPlacedOnTrain { playerIndex, trayId, stock }
ClearanceRequested { trainId, followingInto, occupiedBy }
ClearanceGiven { trainId, allow }
TrainHighballed { trayId, from, to }
TrainMoved { trayId, from, to }
TrainCompleted { trayId, atDivisionPoint }
Consequences
CollisionOccurred { at, trains[], struckCars[], faultPlayer, penalty: 5 }
RedFlagPlayed { playerIndex, avertedAt }
RevenueChanged { playerIndex, delta, total, reason }
CollisionCountChanged { collisionsToday }
GameEnded { result, winner?, reason }
CollisionOccurred carries faultPlayer explicitly rather than leaving clients to derive it. Fault
depends on where the wreck happened — Superintendent for a Mainline card, the local player between
his Limits (§10) — and getting that wrong misattributes a −5 and, in Competitive, contributes to a
floor that ends the game.
4. Views and redaction
Each client receives a projection with other players' private state removed.
| State | Visibility |
|---|---|
| Board: track grids, Offices, Limits, trains, standing cars | Public |
Facilities: boxes, MEN AT WORK, Laborer/Porter usage |
Public |
| Division Yard, Classification Yard contents | Public — physical piles on the table |
| Salvage Yard | Public — face up, explicitly so players can audit discards (§2.6) |
| Department slots (the three face-up cards) | Public |
| Home Office deck contents and order | Secret — never sent, to anyone |
| Home Office deck count | Public — players can see the pile's height |
| A player's hand | Owner only; others see the count |
| Red Flag held | Public — it is a known starting card (Appendix B) |
| Revenue totals | Public — the race is the game |
| RNG seed | Secret — sending it leaks all future shuffles |
View {
public : PublicState -- identical for every client
private : { hand: CardId[], redFlag: boolean }
you : PlayerIndex
canAct : boolean
legalIntents? : IntentSummary[] -- optional server-computed affordances
}
Two redaction traps. First, CardDrawn from the Home Office must omit cardId for every client
except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck
count is public but the order is secret; a naive implementation that ships the deck array and
tells the client not to look is not redaction.
legalIntents is optional but worth it: the server already computes legality to validate, so
returning the affordance set costs little and removes any need for the client to reimplement rules
like turnout directionality or the four-slot consist limit.
5. Ordering and idempotency
- Events carry a monotonic
eventSeqper game. Clients apply strictly in order and request a replay on a gap rather than guessing. - Intents carry a client
seq. The server ignores a repeat of one it has already applied, so a reconnecting client can safely resend anything it is unsure about. - The server never applies two intents concurrently within a game. With one actor at a time this is free — a per-game queue is sufficient and there is no need for anything cleverer at this scale.