Files
2026-07-31 07:19:57 -04:00

10 KiB
Raw Permalink Blame History

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 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.