# Protocol The client↔server message vocabulary, and how per-player views are redacted. Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md). Three message families, matching the flows in [`overview.md`](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`](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 `eventSeq` per 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.