# Multiplayer — design and build plan How Station Master becomes a multiplayer game with an authoritative server, **as a layer added on top of what exists**. Solitaire keeps working exactly as it does today: opened from a static host, played entirely in the browser, no server involved at any point. This is a plan, not an implementation. Written against the engine as built (v0.3.1). It reconciles [`overview.md`](overview.md), [`protocol.md`](protocol.md), [`lobby-and-sessions.md`](lobby-and-sessions.md) and [`deployment.md`](deployment.md) — all written *before* the engine existed — with what was actually built, and with the decisions reviewed and settled in §11. --- ## 1. The constraint that shapes everything > Solitaire still plays the same. No server needed. That decides the architecture: - The engine stays **pure and browser-runnable**. No server-only dependency enters `src/engine/`. - The client runs in **two modes** without forking: authoritative-local (solitaire, today's behaviour) and view-only-remote (multiplayer). - The static deploy keeps working. A server is an *additional* way to run the game. The existing architecture anticipated nearly all of this and the engine was built to it. Most of what follows is assembly. --- ## 2. What holds, and what moved `overview.md`'s core claims survived contact with the implementation: server authority, a pure deterministic engine, two entry points (`applyIntent` / `pump`), events that render standalone, per-player redacted views. Four things moved: **`state = fold(events)` is not true, and the intents are canonical instead.** `applyIntent` does go through the reducer, but the phase driver does not — it mutates and then emits a descriptive event — so fourteen of the forty-six event types are never reduced, including the clock and the whole Mainline phase. This costs nothing, because the plan never needed it: persistence is `{ seed, history }` (§10) and the wire carries `Frame`s rather than events (D2/D3). Reconnection is a fresh `Frame`, not an event tail. **The save format is already the wire format.** A game is `{ seed, history: Intent[] }` and `fromSave` reconstructs it exactly. That is what makes persistence nearly free (§7). **`protocol.md`'s message vocabulary was out of date; fixed in Phase 0.** It had been written from the rules before the engine existed, and the real `Intent` union diverged — different names (`switch.move`, not `Switch.Move`), different shapes (`dropCars` grew `fromNose`; `card.play` grew `variant` and `node`), and intents that did not exist then (`switch.sortConsist`, `newTrain.secondSection`, `maneuver.*`, `redFlag.*`), plus 40-odd rejection codes against a listed 20. **The real types are the protocol**, so `protocol.md` now points at `intents.ts` and `events.ts` and keeps only what the types cannot say. **The client already renders from a projection.** `snapshot()` produces a `Frame`, `actionMenu()` a `Menu`, and the renderers consume only those. This is why the multiplayer client is cheap. --- ## 3. Measurements this plan rests on Taken from 8 four-player bot games and 12 solitaire games, so the sizing is not guesswork. | | value | | --- | --- | | Intents per game (4 players) | ~355, over ~16 Stages | | Intents per player per game | ~89 | | Intents per Stage | 22 — about 5.5 yours, 16.7 watched | | Phase split | Local Ops 78%, Load/Unload 19%, New Train 3% | | A turn: switch / draw / freight agent | 5.8 / 4.3 / 2.0 intents | | Frame | 9.3 KB mean; **2.9 KB** with the board delta'd | | Menu | 2.2 KB mean, 9.8 KB max | | Events per intent | 3.3, ~160 B | | Push rate | ~0.4 per second across a whole game | Two consequences worth stating plainly: **Latency is not the problem.** Watching does not block — pushes arrive asynchronously. The only latency a player feels is on their own ~89 actions: **1.7 seconds across an entire game** on a LAN. Nothing here is a performance decision. **Idle time is the problem.** With four players you watch ~17 of 22 intents per Stage. That is a turn-order property of the rules, not of the implementation — see §11 D19 for what is being kept open about it. > **Open, and not a plan decision:** a 4-player competitive game ends after **~16 Stages of a > possible 60**, well before the 5-day limit. Most likely the collision or revenue floor (§3.4) > firing early. Worth understanding before building a lobby around game length — it may be a balance > bug rather than intent. --- ## 4. The structural idea: a Session boundary Today `main.ts` calls `submit(game, intent)`, which applies in-process and re-renders. Introduce one interface between the UI and the game: ``` Session view() -> Frame what to draw menu() -> Menu what may be done submit(intent) -> Promise propose an action subscribe(cb) -> unsubscribe "something changed, re-render" capabilities -> { undo, saveLocal, newGame } ``` - **`LocalSession`** wraps today's `Game`: applies through the engine, pumps `advance`, keeps `history`, supports undo and `localStorage`. **This is solitaire, unchanged.** - **`RemoteSession`** holds no authoritative state. Posts intents, receives `Frame` and `Menu`, re-renders on push. Undo and local save are absent from `capabilities`, so the UI hides those controls rather than failing when pressed. The client cannot keep authoritative state in remote mode even if it wanted to: it has neither the deck order nor the other players' hands. --- ## 5. What the client must stop doing `main.ts` reaches into `game.state` in **11 places**. Five new `Frame` fields remove all of them: | Reads today | Fix | | --- | --- | | `clock.day`, `clock.stage`, `clock.phase` | already on `Frame` | | `turn.option` | **add** — and under §6 it becomes *your* turn, not *the* turn | | `status`, `outcome` | **add** | | `players` (names and revenues of every seat) | **add** — the scoreboard is public | | `decks.hands.get(actor).length` | **add** as `handCount`, plus per-seat counts | Everything else it draws already comes from `Frame`/`Menu`. **Solitaire-only, gated by `capabilities`:** undo (other players have seen the result), `localStorage` save/restore, new-game-by-seed-in-URL. --- ## 6. Per-player turn state `s.turn` is a **single** `TurnState` — one `option`, one `movesRemaining`, one `freightWorked` — read in 50 places, with a phase driver that walks players one at a time calling `freshTurn()` as it goes. **Phase 0 makes it per-player: `turns: Map`, with a turn created for every player at phase entry.** This is deliberately **behaviour-neutral** — the cursor still advances one player at a time, so play is identical and every existing test still describes the same game. It is done now because the engine is least encumbered now, and because the alternative later means the same work *plus* reworking a client built around "wait your turn". There is no data migration either way: we persist intents, not state. **What it buys later.** There is exactly one `NOT_YOUR_TURN` gate in the engine (`apply.ts:463`, delegating to `isActor`). Once turn state is per-player, allowing genuinely local work to happen off-cursor is a change to that one function: ``` isActor(s, player, intent) local intent -> that player's own turn isn't done shared intent -> player is at the shared cursor ``` An intent is **local** iff it touches only the acting player's Office Area **and** does not advance the shared RNG (three sites: the reshuffle, the D12 that schedules a train, and setup). | Local | Shared | | --- | --- | | `switch.move`, `dropCars`, `sortConsist` | `draw.fromHomeOffice`, `draw.fromDepartment` | | `card.play` — track, facility, office, modifier, enhancement | `card.discard` (buries a shared pile) | | `localOps.choose`, `switch.end`, `draw.end` | `card.play` — **train cards**, which roll the D12 | | | `freightAgent.*`, and the yards generally | That flip is **not** part of this plan. See D19. --- ## 7. Redaction — the trust boundary `protocol.md` §4's table governs, and `Frame` was designed for it: `Frame.hand` is the *viewer's own*, `Frame.deck` is a count, and the seed is not on it. **The redaction surface is four fields, not sixty event types.** An earlier draft of this document claimed the latter and it was wrong — it biased the design. The genuinely secret things are: | Secret | Why | | --- | --- | | `seed`, `rngState` | leak every future shuffle and roll | | `decks.homeOffice` — contents *and* order | §12.1; the count is public, the pile's height is visible | | `decks.hands` — other players' | owner only; counts public | | `cards` (the id→kind map) | the dictionary that turns any leaked id into a known card | Everything else is public by the rules: the board, the Division, trays and consists, the timetable, the yards, the salvage pile, the face-up Departments, revenues, held Red Flags, the clock and whose turn it is. `trainScheduled` is **public** — `trainNumber`, `roll` and `slot` all belong on screen; only its `rngState` field must be stripped. So redaction reduces to: **call `snapshot(s, seat)` and never send `GameState`.** Because `Frame` resolves card names and descriptions server-side, the client never needs the `cards` dictionary at all. **This must be enforced, not assumed.** Phase 2's redaction test is the single most important test in the plan: serialize a seat's payload and assert it contains no other seat's card ids, no deck array, and no rng state. --- ## 8. Server shape One process, one port, same origin. Three layers: ``` transport HTTP: lobby, intents, static assets. SSE: the per-seat stream. | game session one per active game: owns state, serializes intents, pumps advance, | computes per-seat Frame+Menu, appends to the log | rules engine unchanged, imported as-is from src/engine/ ``` The session host is thin, because the engine does the hard part: ``` on intent(seat, intent, seq): if seq already applied: ignore // idempotent resend result = applyIntent(state, seat, intent) if not result.ok: reply Rejected(result.code) append intent to the log pump(state) // drain the automatic phases for each connected seat: push { frameΔ: delta(snapshot(state, seat)), menu: seat may act ? menuFor(seat) : null, lines } ``` `pump` after every intent is what today's `drain()` does, and it is why the Mainline Phase needs no special handling: `pump` stops, and the next push simply carries a `Menu` containing `mainline.clearance` for whoever must rule. **Bots run server-side** using the existing `developerBot`, for seats chosen at lobby time only. --- ## 9. Transport, and multiple addresses **SSE for push, HTTP POST for intents.** ``` POST /api/lobby/create, /api/lobby/join lobby POST /api/intent { gameId, seq, intent } GET /api/stream EventSource — per-seat frames, with Last-Event-ID resume GET / the client ``` Chosen over WebSocket because this game is **idle most of the time** — turn-based with human think-time means a connection sits silent for minutes, exactly when proxies reap sockets. SSE's reconnection and `Last-Event-ID` resume are handled by the browser, and it needs no `Upgrade` support from anything in the path. WebSocket is supported on StartOS (`recipe-multi-interface.md`; `cln-startos` ships one) and remains a contained swap behind the `Session` interface if bidirectionality is ever wanted. Two channels means intents carry a `seq`, which the design already required for idempotent resend. **Players will reach one server at different addresses** — a LAN `IP:port` and a clearnet subdomain, in the same game. That works because the server serves the client from the same origin, so each browser is same-origin with itself and **no CORS is involved at any point**. One rule makes it hold: > The client derives every endpoint from `location`. Never from configuration, never baked in. Two consequences: - **`localStorage` is per-origin**, so a session token exists only at the address it was created at. **A player rejoins at the address they joined from.** This is a stated constraint, not a bug to fix. - The two paths have different reliability characteristics — only the clearnet player traverses TLS termination and ingress. SSE's automatic recovery is what makes that difference not matter. --- ## 10. Identity, access and persistence **Access: a server-wide join secret**, set by environment variable and passed out of band by whoever runs the server. Anyone holding it may create a game, and may join any created game that has not started. No accounts, no user database. *(Revisit — see `TODO.md`.)* **Seating is decided by §4.4's D12** as of v0.4.1, so `seating` is a real permutation rather than the identity mapping. Everything "round the table" — acting order, the deal, the Fedora — is seat arithmetic via `playerLeftOf`, and the three places that were doing it with player indices were found and fixed by turning the roll on. That is the point: the seat/player split is now exercised by every multi-player game instead of only by tests that rotate `seating` by hand. **Identity: a session token scoped to one game**, exactly as `lobby-and-sessions.md` describes. Joining issues it; presenting it *is* the rejoin, because it already names the game and the person: ``` Session token, gameId, player, displayName ``` It names the **player**, not the seat — the two stopped being the same thing in v0.4.0, and Employee Rotation is precisely the case where a token naming a chair would seat someone in the wrong Office. The seat is `seatOf(state, player)`, one lookup and always current. (This said `seat` until the `lobby-and-sessions.md` review.) The join secret is separate and server-wide — typed once and kept per-origin for convenience. It gates entry; the token identifies a seat. **One game at a time per person is the expected usage, and is deliberately not enforced.** Enforcing it would mean a server-scoped token carrying an `activeGame`, and then answering what clears it — a finished game, a player who leaves, a game that never ends — which is real state to get wrong for no benefit. Nothing in the design needs a person to be in only one game, so nothing checks. A browser that ends up holding two tokens simply has two games. **Persistence: `{ engineVersion, seed, config, history: Intent[] }`**, append-only per game, plus a small index. Rebuilding is `fromSave`, already implemented and exercised by every published replay. No state snapshot — see D6. **Games do not survive a rules change, by design.** A saved intent legal under old rules is rejected under new ones; that happened four times in one release. So every game is stamped with its engine version, and on load a mismatch is **refused with an explicit message** rather than silently truncated. The upgrade path is: stop new games on the old version, let running ones drain. **Retention: finished games keep everything**, because replay reveals everything (D20) and the log is all it needs. --- ## 11. Decisions — reviewed and settled | # | Decision | Rationale | | --- | --- | --- | | **D1** | **SSE + HTTP POST** | The game is idle for minutes at a time and players arrive by different paths; browser-native reconnect and resume, no `Upgrade` dependency. Swap is contained behind `Session`. | | **D2/D3** | **Push `Frame` + `Menu`, delta'd. No raw event stream.** | Latency is not the deciding factor (1.7 s per game on a LAN), so decide on correctness: one reducer, one redaction chokepoint, no card dictionary on the client. 2.9 KB per push with the board omitted when unchanged — reusing `replay.ts`'s packing, already tested for losslessness. | | **D4** | **One client bundle**, mode switch | The engine ships either way; in remote mode it is simply not used for authority. | | **D5** | **Persist intents**, not events | Smaller, already the save format, already proven by `fromSave`. | | **D6** | **No state snapshot** | A second format to keep correct, and D7 removes the need. | | **D7** | **Stamp the engine version; refuse to resume a mismatch** | Rules changes invalidate stored intents. Drain before upgrading. | | **D8** | **Bots fill empty seats at lobby time only** | Makes short-handed games and one-person testing possible. Never automatic on disconnect — see the two `TODO.md` items. | | **D9** | **Separate seat from identity, now** | Employee Rotation needs a player's score to follow them while their seat changes. The single hardest thing here to retrofit, and the codebase is smallest today. | | **D10** | **No hotseat** in v1 | Nearly free once seats exist, but a different UX. Additive later. | | **D11** | **Server accepts 1-seat games**, but solitaire stays the static build | Falls out of seats generalising, and is genuinely useful for testing and validation. Not a featured path — playing alone gains nothing from a round trip. | | **D12** | **No spectators** in v1 | A view with no private section and no intent rights. Additive later. | | **D13** | **No accounts** | Display name plus a per-game session token, as `lobby-and-sessions.md` describes. One game at a time per person is the expected usage but is **not enforced** — doing so would add cross-game state to get wrong for no benefit. | | **D14** | **Server-wide join secret**, passed out of band | The server may be clearnet-reachable. Cheapest thing that stops it being someone else's game server. Revisit. | | **D15** | **Build to `deployment.md`'s five portability rules; package as an `.s9pk`** | This is a StartOS packaging workspace and the toolchain is on disk. | | **D16** | **The server serves the client** | Required for same-origin, which is what makes multiple access addresses work without CORS. | | **D17** | **Undo is solitaire-only** | Other players have seen the result. | | **D18** | **Player cap 2-4** for competitive/coop (solitaire is 1) | Per `lobby-and-sessions.md` §2 — sized to what is actually exercised (`test/multiplayer.test.ts` plays 2, 3 and 4 to a finish), not to a guess. This entry read "6" until Phase 4 (v0.5.1); that number was never implemented or tested anywhere and the two docs had drifted apart. Raise it once somebody has played a bigger game and reported back, not before. | | **D19** | **Per-player turn state now; parallel turns deferred** | The model change is behaviour-neutral and cheap today. Whether local work should run off-cursor depends on how often humans choose to switch — 13% for the bot, and the benefit ranges from ~8 minutes to ~27 off an hour-long game between 13% and 50%. Measure with real players, then flip one function. | | **D20** | **Replay reveals everything once the game ends** | Most useful for learning and for arguing about it afterwards; costs nothing extra to retain. | **Still open, and deliberately so:** whether local turns should run in parallel (D19, needs human switching data); whether the join secret is enough (D14); and the ~16-Stage game length in §3, which is a balance question rather than an architecture one. --- ## 12. Build plan Sizes use `components.md`'s scale. Each phase ends somewhere demonstrable. ### Phase 0 — Engine and client preparation · M — **done, v0.4.0** Safe to do now, and all of it improves the code whether or not multiplayer ships. Larger than a typical "phase 0" because two structural changes are cheapest here. 1. **Separate seat from identity** (D9). `Player` carries id, name and revenue; the seat carries the Office. Touches setup, `areaOf`, scoring, seating, the Division build and most tests. 2. **Per-player turn state** (D19). `turns: Map`, one per player at phase entry, `turnOf(s, player)` at 50 sites. **Behaviour-neutral** — the cursor still walks one player at a time, and every existing test must still pass unchanged. 3. Add `option`, `status`, `outcome`, `players[]`, `handCount` to `Frame`. 4. Remove all 11 `game.state` reads from `main.ts`; add a test that it never regains one. 5. Rewrite `protocol.md` to reference `intents.ts` and `events.ts` rather than restate them. **Done when:** solitaire plays identically, the client renders from `Frame` + `Menu` alone, and the engine has per-player turn state that nothing yet exploits. *All five shipped in v0.4.0. Item 1 found a real bug on the way — `awardDeparture` was indexing `s.players` with a seat.* ### Phase 1 — The Session boundary · S — **done, v0.4.0** 6. Define `Session`; implement `LocalSession` around today's `Game`. 7. Move `main.ts` onto it, with `capabilities` gating undo, save and new-game. **Done when:** solitaire plays identically through the new interface. Maximum "nothing appears to have happened"; the regression suite is the proof. *Shipped in v0.4.0. The proof is `test/session.test.ts`, which plays seed 77 to a finish through both routes and asserts the boards and histories match, plus a source check that `main.ts` never regains a `GameState` read or a value import from `game.ts`.* ### Phase 2 — Server core, one game, no lobby · M 8. Process bootstrap: env-configured bind address, port, data directory, join secret; static serving. 9. Game session host — the loop in §8. 10. Per-seat `Frame` + `Menu`, with the board delta reusing `replay.ts`'s packing. 11. **The redaction test** (§7). Fail loudly. 12. SSE stream and `POST /api/intent`, with `seq` and `Last-Event-ID`. 13. `RemoteSession` in the client. **Done when:** two browsers — one on a LAN IP, one on a hostname — play a 2-player game to a finish. ### Phase 3 — Persistence and resumption · S 14. Append-only `{engineVersion, seed, config, history}` per game; index. 15. Load on start; rebuild via `fromSave`; refuse a version mismatch explicitly. 16. **Turn timings**, stored beside the history and never inside it — wall-clock per player per phase, so "how long does a 4-player game take, and which phase is the wait?" becomes a measured answer instead of a guess (`lobby-and-sessions.md` §5). **Done when:** the server restarts mid-game and both clients carry on. ### Phase 4 — Lobby, sessions, reconnection · M 17. Join secret; per-game session token naming the **player** (not the seat); display name. 18. Create/join by game code; bot seats; seating UI showing the west-to-east chain; config locked at start; **2–4 players enforced here**, since the engine enforces nothing. 19. Disconnect keeps the seat and announces it; reconnect replies with a full `Frame`. 20. Host rights pass to the earliest-joined remaining player if the host leaves before start. **No forcing turn timer** — cut in the `lobby-and-sessions.md` review, and in `TODO.md` as something to explore only if halted games turn out to be a real problem. Nothing moves on an absent player's behalf. **Done when:** four people join from four browsers at two different addresses, one closes the tab and rejoins where they left off. ### Phase 5 — Multiplayer content · M 21. The 10 Action and 12 Space-use cards; flip `opponentCardsInDeck` in `setup.ts`. 22. Facing Point Locks, Water Column and Overpass stop being dormant — already wired. **Done when:** an opponent-directed card resolves against another player and the Enhancement that answers it fires. ### Phase 6 — Package for StartOS · S 23. `.s9pk` per the workspace guide: interface, health check, backup of the data directory. **Done when:** it installs on a StartOS box and players on two different addresses play a game. **Shape of it:** Phases 0–1 are low-risk refactoring that stands on its own merits. Phases 2–4 are the real system. Phase 5 is content and independent of the rest. Phase 6 is packaging. --- ## 13. Risks **R1 — The switching UI is still the sleeper.** `components.md` called it that and it remains the hardest interface in the game. Multiplayer does not make it harder, but four people now watch one person use it. **R2 — Redaction must be exactly right.** Everything else degrades gracefully; a redaction bug hands someone else's hand to a player and cannot be walked back. Hence the explicit test, not a review. **R3 — `Menu` peaks at 9.8 KB** against a 2.2 KB mean. If that bites, send placements only for a selected card. Measure before optimising. **R4 — Idle time, not latency, is what makes 4-player games drag.** You watch ~17 of 22 intents per Stage. D19 keeps the door open; the instrumentation to decide it is a few lines and belongs in the first real multiplayer games. **R5 — The two provisional rules are still unplaytested.** The opening deal and departure Revenue are both flagged for review in `TODO.md`. Phases 0–1 are safe regardless; **Phase 2 onward should wait until those settle**, or the server gets built against rules that are still moving.