Files
station-master/docs/architecture/multiplayer.md
T
Jesse e76bd77099 v0.5.1 — multiplayer Phase 4: lobby, sessions, reconnection
A real server existed since v0.5.0 but nobody could reach it without a hand-built ?seat=&secret=
URL. This is what makes it a game you can actually create or join.

The server now hosts more than one game: src/server/lobby.ts (new) is pure logic — creating,
joining, bot seats, host transfer, starting — same split session.ts already draws for a running
game. persistence.ts gained one directory per gameId plus a top-level index so index.ts resumes
every saved game on boot. /api/stream and /api/intent now authenticate by session token instead of
?seat=&secret= — the token alone proves identity (lobby-and-sessions.md §1), so the join secret's
job ends at the lobby door.

Bots fill empty seats at Lobby.Start only, never take over a disconnected human (D8): session.ts
gained driveBots(), playing developerBot forward through consecutive bot seats after every accepted
intent. Disconnect keeps the seat and says so — Push gained an optional presence field, built
entirely by http.ts and never routed through the engine, since a disconnect is transport news, not
a GameEvent. Host rights pass to the earliest-joined remaining player if the host drops before
start.

Client: src/web/lobby.ts adds create/join forms and a live seating screen; localStorage replaces
?seat= for reconnecting straight back into a game already joined. A Multiplayer button sits beside
New game; the New Game dialog itself is untouched.

Found only by the live smoke test, not by typechecking: /api/intent read its token from the JSON
body while the client sends it in the query string (matching /api/stream) — every intent failed
"no such game" until caught by curl-level verification.

Doc fix: multiplayer.md's D18 said the player cap was 6; lobby-and-sessions.md §2 says 2-4 with the
reasoning and the test coverage to back it. The two had drifted apart. D18 now reads 2-4.

Not verified: an actual browser walking through the lobby screens — none available in this
environment, same limitation Phase 2's RemoteSession shipped under. 656 tests, 0 failures.

tools/jitsi-harness/ deliberately left untracked — unrelated side-project work, not part of this
release.
2026-08-21 05:25:47 -04:00

24 KiB
Raw Blame History

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, protocol.md, lobby-and-sessions.md and 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 Frames 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<ok>    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<PlayerIndex, TurnState>, 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<PlayerIndex, TurnState>, 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

  1. Define Session; implement LocalSession around today's Game.
  2. 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

  1. Process bootstrap: env-configured bind address, port, data directory, join secret; static serving.
  2. Game session host — the loop in §8.
  3. Per-seat Frame + Menu, with the board delta reusing replay.ts's packing.
  4. The redaction test (§7). Fail loudly.
  5. SSE stream and POST /api/intent, with seq and Last-Event-ID.
  6. 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

  1. Append-only {engineVersion, seed, config, history} per game; index.
  2. Load on start; rebuild via fromSave; refuse a version mismatch explicitly.
  3. 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

  1. Join secret; per-game session token naming the player (not the seat); display name.
  2. 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.
  3. Disconnect keeps the seat and announces it; reconnect replies with a full Frame.
  4. 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

  1. The 10 Action and 12 Space-use cards; flip opponentCardsInDeck in setup.ts.
  2. 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

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