Files
station-master/docs/plans/jitsi-common-board.md
T
Jesse.MarkowitzandClaude Opus 5 c10f52791e v0.8.0.2 — the speed control that was only ever a URL parameter, and a Day-end
contradiction

Two things found by playing v0.8.0.1, neither in the mechanism itself.

?pace= never worked. index.html's doors are play.html?lobby and
play.html?solitaire, so arriving through the splash replaces the query string and
the play page only ever saw ?lobby — a whole game was played at 1x while believing
it was at 7x. v0.8.0 shipped that parameter as the only way to change speed and the
game's own front door destroyed it. There is a control on the play screen now,
beside zoom, persisted per viewer; the doors carry pace through as well, so the URL
lever is honest for handing two playtesters different speeds. PACE_LEVELS moved to
sim/pacing.ts with DWELL and MAX_PACE — the tuning surface in one file, and
testable. The committed default is unchanged: what it should be is a question for a
game played at a speed that took effect.

And the Day-end dialog said "0 today, 2 in all". advance.ts increments the Day and
then zeroes collisionsToday, and noteDayEnd() fires when the Day goes up — so the
dialog reporting the Day that just finished was drawn from the very frame in which
that Day's count was reset. Reproduced on four of five seeds before changing
anything. The count is captured at the rollover now; it is not derivable on the
client, because in multiplayer the push announcing the new Day is the same push
that carries the reset. And "today" was the wrong word regardless: it names the Day
instead — "Collisions: 2 on Day 1, 2 in all".

Unrelated to v0.8.0 — that one has been wrong since the dialog was built for
Gitea#10, and needed somebody to play a Day with a collision in it and then read
the summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
2026-09-09 20:08:39 -04:00

64 KiB
Raw Blame History

Station Master Jitsi Common Board Implementation Plan

Status (2026-09-09): STEP 1 IS BUILT AND SHIPPED. Steps 2-7 are unimplemented.

Step 1 landed across four releases rather than one — v0.7.9.2 (the two narration leaks), v0.7.9.4 (the projection helpers and the redaction net), v0.7.9.5 (the narration path), and v0.7.9.8 (this reconciliation). One of its items is struck off rather than built; see § Public game projection.

THE RELEASE SPLIT — read this before picking up any step

Settled with Jesse 2026-09-09. The document below was written as one seven-step delivery and its ordering still reads that way, so this section and § v0.8.0 are the authority on what belongs where.

Release What Why here
v0.8.0 TODO #13/#15/#18 — the watchable table. The step collector, steps on the Session interface, foreign-district rendering, the client animation queue, pacing, and the behind-counter. § v0.8.0 is the design. #13 is the point. "Watching on a TV is the bonus" — Jesse, 2026-09-09.
v0.8.1 The seatless board page. display.json, viewToken, /api/display/stream, /display.html, an all-districts layout. Most of step 2, and step 3 without its canvas. Cheap once 0.8.0's foreign-district rendering exists; nothing in it moves #13 forward.
v0.9.0 The Jitsi publisher. Steps 5-7, plus step 3's canvas capture pipeline. Needs Chromium in the image and hardware measurement. Held off deliberately.

What 0.8.0 is FOR, in Jesse's words (TODO #13, 2026-08-29): "It's not fun to do my turn and have magic happen in the background and then have to figure out what others did." And 2026-09-09, on what he most wants to watch: "I definitely want to watch other players struggle with the switching exercises … I don't think reading the switching in the log will be anywhere nearly as interesting as watching the trains actually move on the board."

Decisions taken 2026-09-09 that the text below has NOT been rewritten around

  • Frame.cells is ONE district — the viewer's own (view.ts:510, from areaOf(s, viewer)). So a step stream alone does not answer #13: the data would arrive and have nowhere to be drawn. Rendering a district you do not own is the core of 0.8.0, not part of the seatless page. This is the correction that set the split above; it was mis-assigned until 2026-09-09.
  • No WebSocket, and no new runtime dependency. The control channel exists only to join the supervisor to the headless agent — a handful of messages per publisher lifetime, on loopback. package.json has no dependencies key and the wrapper's runtime image copies only package.json and src/ with no node_modules, so adding ws changes the deployment model rather than adding a dependency. Use SSE down + POST up, the pattern src/server/http.ts already implements for players (/api/stream + /api/intent). The proven Jitsi code ports either way: engine/communications/CommunicationsClient.ts in the sibling repo references the control layer in two comments and nothing else. 0.9.0 work.
  • protocolVersion is added in 0.8.0. See § Public game projection.
  • 0.8.0 needs no HTTP work at all. The startServer() refactor and the project's first HTTP test harness existed to test /api/display/stream, which is now 0.8.1. Push.steps is built in session.ts, and broadcastGame() forwards whatever push it is handed, so http.ts does not change and the tests land in the existing test/server/session.test.ts.
  • Solitaire is a special case of multiplayer, not a second implementation — Jesse's stated design direction, to minimize rule and implementation drift. src/web/session.ts already draws that seam: "the page … does not care whether the rules are being applied a function call away or across a network." Every mechanism below hangs off Session, with LocalSession and RemoteSession both feeding it. Solitaire therefore gets #18 through the same code path multiplayer gets #13 through.

This document has drifted from the code and is no longer the authority on what exists. It was written on 2026-08-27 against the code of that date, and the "Current code findings" under each step describe faults that were then real — several are now fixed, and reading them as present tense will send you to fix things twice. Where a step is marked built, src/sim/view.ts, src/server/, and the tests named in TODO.md are the authority. Steps 2-7 were never implemented and their findings have NOT been re-verified against the current code; check each before building on it.

v0.8.0 — The watchable table

This section supersedes the parts of steps 2-4 it covers. Where it and a step below disagree, this wins; the steps keep the material that is still 0.8.1/0.9.0 work. Designed with Jesse 2026-09-09 in conversation; every measurement quoted was taken from the code and the published replays that day.

The shape

One mechanism, four consumers, no branch between solitaire and multiplayer:

submit()  ──►  collector (sim/)  ──►  DisplayStep
                                        │
                    ┌───────────────────┴───────────────────┐
            LocalSession.steps()                    Push.steps ──► RemoteSession.steps()
                    └───────────────────┬───────────────────┘
                                        ▼
                          client animation queue (one impl)
                                        ▼
                    board render · caption · behind-counter · skip

1. The collector

BUILT 2026-09-09 — src/sim/display-step.ts, test/watchable.test.ts. Two things below were wrong in a way worth recording, because both made the job smaller.

ONE HOOK, NOT TWO. This section said to wire two call sites in GameSession and let LocalSession do its own thing. It does not need to: src/server/session.ts imports submit from src/web/game.ts, so solitaire, live multiplayer and every bot turn already funnel through one function. Collecting inside submit() covers all three, and that is what makes solitaire a special case of multiplayer here rather than a parallel implementation.

REPLAY IS INERT FOR FREE. fromSave and fromMultiplayerSave rebuild a game with applyIntent

  • record + drain directly rather than through submit, so a resumed server does not re-emit the whole game as steps. No guard is needed. But the property is load-bearing rather than lucky — move a replay path onto submit() and it silently becomes the #97-class bug this section feared — so test/watchable.test.ts pins it.
  • One step per accepted intent, including automatic work drained behind it. Never one step per GameEvent — an intent drains pump() work and the event list is not a complete reducer.
  • Narration's high-water mark is taken inside submit(), which brackets record and drain and is therefore the only place that knows what one intent said. Not sentLines: that is per-seat and is mutated by linesSince() as a side effect of building a push.
  • Capture the frame immediately; never retain a mutable GameState reference for later projection, or every retained reference resolves to the final state.
  • Accumulated on Game beside log, cues and announced and drained by takeSteps() the way takeMoment() drains the rest — the established convention for "the model accumulated something, the view takes it". pushesForAll() drains ONCE per broadcast, not per seat.

2. Delivery — on the Session interface

Session gains steps. LocalSession emits them from its own submit(); RemoteSession reads them off Push.steps. The page consumes one queue and cannot tell which it has.

Push gains steps?: DisplayStep[], following the presence precedent — Push.frame is already optional, and presence went in as a field rather than a second SSE event type for the reason recorded at http.ts:210: "one message shape for the client to parse."

http.ts does not change. broadcastGame() forwards whatever push session.ts builds.

3. What a step carries, and what animates

Public steps animate the board; the private Frame supplies hand, menu and objective.

Your hand never changes because somebody else moved. What can change splits cleanly: revenue is already in PublicFrame (players carries it) so it animates; menu and blocked are recomputed and arrive with the final coalesced Push, as today. So a step carries a PublicFrame delta and adds no new redaction surface — it reuses the projection test/redaction.test.ts already guards.

Rejected: emitting N per-seat redacted Frames per intent. It multiplies both the projection work and the redaction test surface, and buys nothing — a seated step would be a Frame, which is redacted per seat, so it could not reuse the public delta anyway.

Delta the districts per seat, not as one array. Measured 2026-09-09 over a 300-step 4-player game (sim/public-delta.ts now built, test/public-delta.test.ts pins reconstruction): a full frame every step is 20.4 KB/step, 6.0 MB over the game; a frame-delta-style whole-array compare is 3.0 MB (51%); keying by seat is 2.4 MB (40%), saving a further 657 KB, 22% over the whole-array form. sim/frame-delta.ts hardcodes BOARD_KEYS = ['cells','facilities','division'] against Frame; PublicFrame has no top-level cells/facilities — they live inside districts[], one per seat, which is where nearly all the bytes are. One accepted intent changes one district, so a whole-array comparison resends every other player's board on every step. Keep deltaFrame/applyDelta's "null means unchanged" convention; replace the key set.

protocolVersion goes on the ENVELOPE, not on PublicFrame. The original sketch put it inside the frame; it does not belong there. PublicFrame's property list is an allow-list that test/redaction.test.ts enumerates, so a transport concern living in it would have to be declared public game state, which it is not. The step is the message; the message carries the version. Built as DISPLAY_PROTOCOL_VERSION in sim/display-step.ts.

4. Foreign-district rendering — THIS IS #13

Frame.cells is one district, the viewer's own. A step stream without this is data with nowhere to go, so this is the feature rather than a supporting part of it.

  • Render any seat's district from PublicDistrict.
  • Focus follows the actor — the district panel shows whoever is acting, theirs while they switch and yours when it is your turn. Same rule the seatless board will use in 0.8.1: acting player's seat, else most recent actor's seat, else seat zero.
  • Resolve owner from seat on every frame, so Employee Rotation relabels a district in place rather than moving it.
  • Frame already carries whereFrom — "Origin of a Move, so the crew's journey is visible rather than a chip teleporting" — which is the same idea for the viewer's own moves. Extend it, don't invent a second one.

5. Pacing — dwell by kind

Measured 2026-09-09, and the measurements decide the model. From public/replays/: ~60 stages per game and ~5 intents per player per stage, so a 4-player table generates ~15 other-player steps per stage. Burst sizes inside one switching turn, in the switching-heavy seed (seed-1917398): 14, 6, 6, 6 — and trayMoved's own narration says "N of 6 Moves left", so six is the engine's cap per crew. Two of the three published replays contain zero switch.move: bot switching is clustered, not spread.

So a uniform budget spread over the queue does exactly the wrong thing — it steals time from the 6-move switching burst to spend on the 44 draw.end and 60 loadUnload.end. Assign dwell by kind and let the total fall out.

sim/pacing.ts — shared, so the 0.8.1 seatless page paces identically and the TV and the play screen never disagree about how fast the game looks:

export type StepKind = 'switching' | 'action' | 'bookkeeping';

/** THE TUNING TABLE. Dwell in ms per kind. Start generous; tune down by playing. */
export const DWELL: Record<StepKind, number> = {
  switching:   1000,  // switch.move / dropCars / sortConsist / maneuver.*
  action:       250,  // draw.from* / card.* / newTrain.placeCar / porter.* / laborer.*
  bookkeeping:    0,  // *.end, and any phase where nothing happened
};

Jesse 2026-09-09: start switching at 1s and tune down, and "make sure that the tuning parameters for the delays are easy to set." Three levels, deliberately:

Level Where Reach
Committed default the table above needs a web rebuild — in the .s9pk, a release
Live per-viewer pace in Settings (localStorage), a multiplier; 0 = off no rebuild
Per-session ?pace= URL parameter matches the existing ?seed= convention; hand two testers different links

Not in game-creation settings. Jesse, 2026-09-09: "for now, they should not be in the game creation settings, but we may want to put them there later." Correct on its own terms — dwell is presentation, not a rule, and config rides along in saves and replays. Settings already carries the argument for this: "A save is the seed plus the intents and has to stay portable; none of this belongs in it, and in a multiplayer game two players may reasonably want these set differently." The migration path is cheap: because the table is shared and the override is one scalar, moving it to game config later means adding a config field that supplies the multiplier's default. The table, the classification and the queue do not change.

Pacing is client-side only. The server emits steps as fast as it likes, which is what keeps Gitea#20's "do not slow the authoritative game" true.

Arithmetic, because it is the reassuring part: ~16s of switching plus ~25s of one-shots ≈ 40s of animation across a whole 60-stage game, against 3.6 minutes for a uniform 700ms. Tiering gives better switching visibility for a fifth of the total time. One complete switching exercise is 6 × 1s = 6s to watch.

6. The behind-counter, the caption, and skip

Jesse's design, 2026-09-09, and it resolves the "it's your turn but the board is stale" question that had two unattractive answers before it (hold the turn indicator, or show both silently):

"If I saw the counter as I'm watching the board go 17, 16, 15 … and I got impatient, and I could just click a button and have it skip all the rest."

One row, not three additions — the slot between #turnchart and <main> already holds #phasenote, #announce and #presence, and the palette is spoken for (violet = where you are, amber = clickable, green/red = good/bad):

[13 behind]  Player Alice moved Train 12 (1,2) → (1,3) via (1,1)   [Skip]
  • The counter is queue.length — client-side, derived, zero protocol impact.
  • Count only steps that will dwell. With bookkeeping at 0ms, a backlog of 17 where 12 are *.end would read "17", plummet to 5 instantly, then crawl. Thirteen dwelling steps means thirteen things you are going to watch.
  • The caption is #15. TODO's Reference · #15 asks what the unit should be and concludes that in multiplayer it is "everything that happened while you were WAITING" — which is what the queue holds. So #15 is this row, not a separate feature.
  • Skip costs the animation and never the information. Everything skipped is already in the history log. That is what makes the button safe to press without hesitation.
  • Amber for the button. Self-hides at zero, so solitaire only sees it during an automatic-phase run.
  • Input is never blocked. Any input skips to current. pace = 0 turns the whole thing off, which is also TODO #18's "a player who has seen it a hundred times will want it off" — no second mechanism for it.

7. #18 becomes a dwell setting

Not a feature. Phases where nothing happened dwell at zero — Jesse, 2026-09-09: "if nothing happens during a phase then we shouldn't lose time to it", and on solitaire, "you kind of look to see and guess, 'Oh, I guess nothing happened in those phases'", which is acceptable. Phases where something happened get a beat through the same queue.

A flat second per phase is explicitly rejected: 5 phases × ~60 stages is about five minutes per game of enforced dwell, most of it spent on phases where nothing happened. TODO's Reference · #18 reached the same figure from the other direction (48s/Day).

Solitaire inherits all of this through LocalSession rather than being special-cased.

8. Carried along — switching logs unattributed

record() attributes with const mine = who !== null && 'player' in e (web/game.ts:1188). trayMoved, carsCoupled, carsDropped and consistSorted carry no player field — measured 2026-09-09, and they are the only events in their class that do not. cardDrawn, cardPlayed, cardDiscarded, carPlacedOnTrain, loadStarted, loadCompleted, flyingSwitch and localOpsOptionChosen all do.

So switching — the one class of action Jesse most wants to follow — logs unattributed and with tone: 'plain' instead of 'act', meaning it does not even read as somebody's move:

Player Alice chose to switch            ← attributed
CREW moved (1,2) → (1,3) — 4 of 6       ← whose train?
CREW coupled 2 cars                     ← whose?
Player Alice finished Local Operations  ← attributed

An attributed bracket around unattributed contents. Add player to those four events. Fixed here rather than filed, because it is the same feature.

Tests

  • Collector: one human intent with no bot response; one human intent followed by several bot intents; consecutive bot turns; bot pending decisions; automatic engine work inside one intent; ordering of narration against frames; sequence continuity; player pushes still coalesced; no steps emitted during resumeSession(); reconstructed state matching publicSnapshot() after the final step.
  • Public delta: per-seat district deltas reconstruct a frame identical to a fresh full projection.
  • Redaction: done 2026-09-09, by folding the steps into everythingSeatSees so that every existing case covers them — the blind draw, the pending decision, Employee Rotation before and after the seating moves, the reconnect and the played-out game — rather than adding one test beside them. Doing it surfaced two false positives in the existing name-based heuristic, neither caused by this feature, and the distinction they forced is worth keeping:
    • A card NAME is circumstantial evidence; a card ID is proof. Ids are searched everywhere. Names are not searched in two places that are legitimately entitled to carry them: lines naming a face-up pile (§2.6 — a Department or the Salvage Yard is public, so "Ann discarded Train 6 face-up on Department 3" is the record working, and it stays in the log after she takes it back), and the accumulated step frames, which are a record of what was public over time rather than a view of the position now. What guarantees a step frame is clean is the allow-list test on PublicFrame, not a substring search over its history.
    • The harness also passed g.log into snapshot() for the Frame's own lines, which production has not done since #97. Now [], matching frameFor(). The log is still audited in full, once.
    • Verified by injecting the v0.7.9.2 blind-draw leak and confirming the net still fails — both the dedicated test and, independently, the new step coverage. A relaxed safety test that has not been shown to still bite is not a safety test.
  • Pacing: kind classification for every intent type in the Intent union, so a new intent cannot land silently in the wrong tier; dwell arithmetic against the table; pace = 0 produces no dwell.
  • Queue: step order preserved; skip drains and applies final state; counter counts dwelling steps only; a stopped queue stops its timers.
  • Attribution: each of the four switching events narrates with the acting player's name and act tone.
  • Solitaire: LocalSession produces the same steps for the same intents as GameSession does.

Summary

Add a privacy-safe common game board that can be viewed in a browser and published into the game’s Jitsi meeting by a server-managed headless Chromium participant.

The publisher represents the game table, not a player or bot. Human and bot actions both update the same public board. No hands, objectives, legal moves, random seed, private draws, or other player-only state may enter the display data path.

The implementation is divided into independently useful stages:

  1. Establish a secure public-state projection and close existing narration leaks.
  2. Add a public display stream, credentials, persistence, and reconnect behavior.
  3. Build the reusable 1280×720 common-board renderer.
  4. Preserve individual human and bot actions as display animation steps.
  5. Add a minimal visual-only Jitsi engine.
  6. Supervise one headless Chromium process per published game.
  7. Integrate lifecycle, configuration, packaging, health, and live verification.

Steps 1-4 are v0.8.0 (1 shipped); steps 5-7 are v0.9.0 — § THE RELEASE SPLIT.

The browser display remains useful without Jitsi. The public projection and leak fixes improve multiplayer security even if no visual display is deployed.

Research incorporated

This plan is based on the current Station Master server, engine, multiplayer view, persistence, and untracked Jitsi harness, plus a read-only review of the sibling jitsi-transcription packaging repository at commit 9cae1877a9d9a9f489de8f870dad6ff011521c9d.

The Jitsi Transcription review materially changes the earlier architecture:

  • Use direct child_process.spawn Chromium supervision, not Playwright or CDP.
  • Use one Chromium process and temporary browser profile per published game, not multiple contexts in one shared browser.
  • Copy the minimal proven Jitsi/control patterns into Station Master; do not create a runtime dependency on the sibling repository.
  • Treat conference.authenticationRequired as a retryable “waiting for moderator” state.
  • Use the exact StartOS-proven Chromium flag set.
  • Explicitly disable third-party requests in JitsiMeetJS.init.
  • Gracefully leave Jitsi before terminating Chromium to reduce ghost participants.
  • Package Chromium and tini; visual-only publishing needs neither PulseAudio nor Xvfb.
  • Default to one concurrent publisher until target-hardware measurements justify more.

The sibling repository had unrelated local modifications and untracked files. They are not part of this plan.

Public interfaces and types

Public game projection

Introduce dedicated allow-listed types. Do not derive them with Omit<Frame, ...> because new private Frame fields could then leak automatically.

RECONCILED 2026-09-07 (v0.7.9.8). Step 1 is BUILT, and what shipped is not shaped like the sketch below. This block was the design; PublicFrame in src/sim/view.ts is now the authority, and test/redaction.test.ts's allow-list is the enumeration of it that fails when it changes. Read those two, not this, when building steps 2-7. The differences that matter:

  • The shape is FLAT, not grouped. There is no clock, config, scoring or deckCounts object. Their contents sit at the top level. All 38 properties, which is the same list as test/redaction.test.ts's allow-list — day, stage, clock (a time string), phase, phaseKey, actor, superintendent, deck, departments, departmentsWhat, departmentDepth, salvage, yards, timetable, timetableWhat, houseRules, mode, optionalRules, days, minCombinedRevenue, maxCollisionsPerDay, maxCollisionsTotal, collisionsToday, collisionsPrevDay, collisionsTotal, status, outcome, extraDays, extensionVotes, official, tally, players, openingRolls, trains, crewTrays, queued, division, districts. The first 36 come from projectSharedTable; division and districts are added by PublicFrame itself.
  • protocolVersion was NOT built and exists nowhere in the repo. Decided 2026-09-09: add it in step 2. display.json carries its own schemaVersion, and the SSE wire format is a second, independent contract — parsed in 0.9.0 by a headless agent page that lives for hours, where build-web.ts's cache-busting does not help.
  • redFlagHeld is STRUCK OFF, not deferred. See below.
  • Fields gained since this was written that the renderer should know about: crewTrays and queued (#98, the Crew Tray pool and the trains waiting for one), and on each district's cells heldAtLimits (#99, a train stopped on the Limit Track) and enhancementsSpent (#101, a dispatch device spent for the Day).

Why redFlagHeld is struck off rather than built. The premise does not hold in this codebase. decks.redFlags is written in exactly one place — setup.ts, from config.optionalRules.emergencyToolbox — and never again; redFlag.play emits phaseEnded and does not spend it. So every player holds one or none does, decided before the deal. A per-player redFlagHeld would be optionalRules.emergencyToolbox copied N times, already public, while telling every reader of the common board that it varies by player and may change mid-game. The invariant is pinned by test (test/display-gaps.test.ts) so this does not get re-raised from the plan text: if the rule ever becomes per-player, that test fails.

The design as originally written, kept for the reasoning:

type PublicPlayerView = {
  index: PlayerIndex;
  seat: PlayerIndex;
  name: string;
  revenue: number;
  handCount: number;
  redFlagHeld: boolean;
};

type PublicDistrictView = {
  seat: PlayerIndex;
  player: PlayerIndex;
  cells: readonly CellView[];
  facilities: readonly FacilityView[];
  runningRow: number;
  limits: unknown;
};

type PublicFrame = {
  protocolVersion: 1;
  status: GameState['status'];
  clock: PublicClockView;
  config: PublicConfigView;
  scoring: PublicScoringView;
  players: readonly PublicPlayerView[];
  division: readonly DivisionView[];
  districts: readonly PublicDistrictView[];
  deckCounts: PublicDeckCounts;
  departments: PublicDepartmentsView;
  salvage: PublicSalvageView;
  yards: PublicYardsView;
  timetable: PublicTimetableView;
};

Reuse existing view types only after verifying every reused field is public. Create narrower public variants where an existing type includes viewer-specific or private data.

The projection must never include:

  • viewer
  • card identities in any player’s hand
  • justDrawn
  • objectives
  • decisions or private prompts
  • menus, actions, legal moves, or blocked reasons
  • seed or RNG state
  • raw GameState
  • intent history
  • private event details
  • unresolved internal ordering data
  • full shared narration logs

Display stream

type PublicFrameDelta = PartialPublicFrameDelta;

type DisplayReset = {
  type: 'display.reset';
  seq: number;
  frame: PublicFrame;
};

type DisplayStep = {
  type: 'display.step';
  seq: number;
  actor: {
    player: PlayerIndex;
    seat: PlayerIndex;
    source: 'human' | 'bot';
  };
  frame: PublicFrameDelta;
  lines: readonly PublicNarrationLine[];
};

type DisplayMessage = DisplayReset | DisplayStep;

A new or reconnected display client always receives display.reset. Subsequent accepted intents produce ordered display.step messages. The server does not replay missed steps; reconnecting clients reset to the latest public state.

Jitsi publisher state

type JitsiPublisherState =
  | 'disabled'
  | 'queued'
  | 'launching'
  | 'connecting'
  | 'waiting-for-admission'
  | 'waiting-for-moderator'
  | 'publishing'
  | 'reconnecting'
  | 'stopping'
  | 'failed';

Expose sanitized state, last transition time, and a safe error summary through the existing session/health surfaces. Never expose meeting credentials, display credentials, Jitsi tokens, XMPP service credentials, or browser launch URLs.

Step 1 — Secure public-state projection

BUILT — v0.7.9.2 through v0.7.9.5. Everything in "Required changes" below shipped except the Red Flag holder, which is struck off (§ Public game projection). Mapping to the work items in TODO.md: the projection helpers and currentActorOfState are #95, the systematic redaction net and its allow-list are #91, the seed and blind-draw narration leaks are #92, and "stop passing the full game log into frameFor()" is #97 — which also fixed a reconnect bug the duplicate had been masking, so read that entry before touching narration in step 2.

The findings below are as of 2026-08-27 and are now HISTORY, not a task list. Two are worth carrying forward anyway: districts must be keyed by SEAT with ownership resolved through playerAtSeat (Employee Rotation), and the public view must be composed UPWARD from shared helpers, never by calling the player snapshot() once per seat. Both are load-bearing for step 3.

One finding was struck off on measurement rather than fixed: it warns that a display reading clock.currentActor could highlight the wrong district during a decision. Across six seeds and 3,600 decision points that field and actingPlayer never disagreed. currentActorOfState exists anyway as the one place to ask — and #96 later found a real instance of the same class in a state nobody had checked, the §3.3 vote, where the game is not active at all.

Current code findings

src/sim/view.ts currently builds Frame for one real viewer and defaults that viewer to player zero. It combines shared table state, one district, and viewer-private fields. Calling it for a spectator would silently expose player zero’s district and private information.

src/server/session.ts calls:

snapshot(game.state, game.log, ..., seat)

This embeds the complete shared narration log in every Frame, while Push.lines also sends narration incrementally.

Existing narration has at least two privacy leaks:

  • newMultiplayerGame() places the seed in the shared log.
  • A blind Home Office draw can resolve the drawn card to its real name in shared narration.

Existing redaction tests pass [] for narration and mainly search internal IDs, so they do not detect resolved card names or seed text in the full serialized payload.

currentActor(game) accounts for Superintendent/pending decisions, while snapshot() reads state.clock.currentActor. A public display using the latter could highlight the wrong district.

Employee Rotation means district ownership cannot be assumed to match player index. Districts must be keyed by seat, with current ownership resolved through playerAtSeat.

Required changes

Create explicit projection helpers in the simulation/view layer:

  • projectDistrict(state, seat)
  • projectDivision(state)
  • projectSharedTable(state)
  • publicSnapshot(state)
  • currentActorOfState(state)

Refactor the player snapshot and public snapshot to share only safe lower-level projection helpers. Do not implement the public view by repeatedly calling the player snapshot() function.

Make the existing actor logic use the same engine-level helper so player views, bot execution, and the common board agree during decisions and Superintendent actions.

Add the Red Flag holder to the public player projection. It is public game state but is currently absent from Frame.

Remove seed narration from multiplayer game creation. Retain the seed only in persistence and administrative/replay data.

Change blind-draw narration to a generic public line such as “Home Office drew a card.” Keep the card identity available only to the drawing player through the existing owner-only justDrawn mechanism.

Stop passing the full game log into frameFor(). Continue sending sanitized incremental narration through Push.lines.

Tests

Add tests that serialize the entire public frame and player pushes, then search for:

  • every opponent hand card ID
  • every opponent hand card display name
  • current objective IDs and names
  • justDrawn for the wrong player
  • seed values and seed narration
  • private decision/menu/action data

Cover:

  • a newly created multiplayer game
  • a blind Home Office draw
  • a pending decision
  • Superintendent acting
  • Employee Rotation before and after ownership changes
  • reconnect pushes
  • a finished game

Acceptance requires an allow-list review of every PublicFrame property. Passing redaction tests alone is insufficient.

Step 2 — Display credentials, stream, and persistence

SPLIT (2026-09-09). The display-step stream part of this step is superseded by § v0.8.0, which puts steps on the Session interface and on Push.steps — no endpoint, no credential, and no change to http.ts. What remains here is v0.8.1: display.json, the viewToken, the /api/display/stream SSE endpoint, /display.html, the security requirements, and the startServer() refactor plus the first HTTP test harness that testing those needs. protocolVersion and the per-seat public delta moved into 0.8.0. Findings below are from 2026-08-27 and unverified.

Display metadata

Create separate per-game display metadata rather than extending the authoritative game save:

type SavedDisplayMetadata = {
  schemaVersion: 1;
  room: string;
  viewToken: string;
  publisherToken: string;
  nextSequence: number;
  createdAt: number;
};

Store it in the same per-game data directory as display.json, using the persistence layer’s existing atomic-write pattern.

Generate:

  • a cryptographically random Jitsi room name
  • a high-entropy viewToken
  • a separate high-entropy publisherToken

Do not derive any token from game ID, room name, player token, seed, or timestamp.

For older saves without display.json, generate the metadata once on resume and persist it. Failure to initialize display metadata must disable display/Jitsi for that game without preventing the underlying game from resuming.

HTTP endpoints

Add in 0.8.0:

  • GET /display.html#token=<viewToken> — manual common-board page
  • GET /api/display/stream?token=<viewToken> — public-board SSE

Deferred to 0.9.0 with the publisher (§ THE RELEASE SPLIT):

  • GET /display-agent.html — internal headless publisher page
  • the supervisor/agent control channel, as SSE down + POST up, not a WebSocket upgrade

Extend the authenticated game/session response with:

  • display URL
  • sanitized publisher state ('disabled' until 0.9.0)

The Jitsi meeting URL joins that response in 0.9.0.

The browser display reads the fragment token, removes it from the visible address if practical, and supplies it to the SSE request. Fragments keep the credential out of the initial HTTP request and normal server access logs.

Do not add a publisher-configuration HTTP endpoint. The internal agent receives its meeting configuration and view token over its authenticated control channel.

0.8.0 must not presume an upgrade handler exists. startServer() in src/server/http.ts:169 returns void today and there are no HTTP-level tests in the project at all — test/server/ is lobby, persistence and session only. Step 2 therefore builds the project's first HTTP test harness on top of the startServer() refactor, which is a larger opening move than the one line it gets below. TODO #102 is the record of why this half of the codebase was untested until v0.7.9.8.

SSE behavior

Maintain a display subscriber set per game.

On connection:

  1. Authenticate viewToken.
  2. Produce the latest PublicFrame.
  3. Send display.reset with the current sequence.
  4. Continue existing heartbeat behavior.
  5. Send later display.step messages in sequence order.

Use a dedicated public-frame delta function rather than the player Frame delta. The observed public snapshot grows enough during longer games that full frames for every action would be wasteful.

This is new code, not a reuse, and the shape differs from the player delta (checked 2026-09-09). src/sim/frame-delta.ts hardcodes BOARD_KEYS = ['cells', 'facilities', 'division'] against Frame, but PublicFrame has no top-level cells or facilities — they live inside districts[], one entry per seat, which is where nearly all of the bytes are. Delta districts per seat, not as one array: a single accepted intent changes one district, so a whole-array comparison sends every other player's board again on every step. deltaFrame/applyDelta's "null means unchanged, merged against the last full frame the receiver holds" convention is worth keeping; the key set is not.

If an SSE client is slow or disconnected, close it and let EventSource reconnect to a new reset. Do not keep an unbounded replay buffer.

Security requirements

  • Compare tokens without placing them in error messages.
  • Do not log query strings or control registration messages containing tokens.
  • Never accept game intents through display endpoints.
  • Do not let a view token register as a publisher.
  • Apply payload and connection limits independently from player SSE.
  • Keep display failure isolated from player pushes and game persistence.

Tests

Add HTTP tests after refactoring startServer() to return the server/listening handle needed by tests.

Cover:

  • valid and invalid view tokens
  • publisher token rejected as a view token
  • reset on first connect and reconnect
  • monotonically increasing sequence IDs
  • public deltas reconstruct the same frame as a fresh reset
  • heartbeat behavior
  • no private fields in raw SSE bytes
  • legacy save migration
  • atomic display.json persistence
  • display failure not interrupting /api/intent or player SSE

Step 3 — Reusable common-board renderer

SPLIT (2026-09-09). The canvas half of this step exists only to feed WebRTC — canvas.captureStream(10) is the only way to hand a rendered board to Jitsi, and DOM cannot be captured into a MediaStream. A TV or an OBS browser source renders HTML directly, and more crisply. So: the 1280×720 canvas, the 10fps draw loop, contentHint: 'detail', SVG→image rasterization and the content-keyed image cache are all v0.9.0. The layout work — an all-districts seatless page, and adapting the Division/office SVG generators to render without a private viewer — is v0.8.1, and is cheap once 0.8.0's foreign-district rendering exists. The animation queue and the focused-district rule moved into 0.8.0 (§ v0.8.0 §§ 4, 6).

Renderer structure

Build one renderer shared by:

  • the manual browser display
  • the headless Jitsi publisher page

Use a fixed 1280×720 canvas at 10 frames per second. Set the captured video track’s contentHint to "detail".

Keep the canvas palette stable. The Jitsi Transcription prototype deliberately changed color themes to prove updates were arriving; that behavior strobes during frequent game actions and must not enter the product.

Separate the renderer into:

  • a pure layout/view-model stage
  • image/SVG preparation
  • canvas drawing
  • animation queue management

Layout

Use this fixed layout:

  • Header: game name/status, day/stage/phase, current actor, Red Flag.
  • Main left: persistent Division board.
  • Main right: focused district.
  • Footer/side panels: player standings, hand counts, timetable, public decks/resources, and recent narration.
  • Compact district summaries: every district’s owner, revenue, and operational status.

The focused district is:

  1. the acting player’s current seat;
  2. otherwise the most recent actor’s seat;
  3. otherwise seat zero.

Resolve owner from seat on every frame so Employee Rotation updates the labels without moving the district itself.

The complete PublicFrame carries all public districts even though the 720p layout focuses one at a time.

Existing assets

Reuse the existing Division and office SVG generators and exported board CSS. Adapt their APIs so the Division roster can be rendered without a private viewer.

Render SVG output into canvas-safe images. Cache images by serialized SVG/content key and invalidate only when the corresponding public model changes.

Do not recreate game rules or labels inside the renderer. The projection layer supplies display-ready public labels.

Display behavior

  • Human and bot actions use the same animation path.
  • Normal display-step duration: 700 ms.
  • If the queue exceeds 12 steps, use 200 ms catch-up transitions.
  • A reset clears queued animations and renders immediately.
  • Rendering or asset failure keeps the previous good frame and reports a sanitized diagnostic.
  • Recent narration is bounded and uses only sanitized DisplayStep.lines.
  • No server-side sleeps are permitted.

Update scripts/build-web.ts to explicitly build/copy the new display entry points, HTML pages, styles, and Jitsi vendor asset. The current static build manually lists its artifacts, so relying on automatic discovery will omit the pages.

Tests

Test the pure layout model for:

  • focused-district selection
  • Employee Rotation ownership
  • Superintendent decisions
  • two through four players
  • empty and dense boards
  • long player/facility names
  • finished-game state

Test renderer lifecycle with a fake canvas/image layer:

  • reset clears the queue
  • step order is preserved
  • catch-up speed activates at the threshold
  • stopped renderers stop timers and media tracks
  • no private input type is accepted

Perform visual review at 1280×720 and as a reduced Jitsi tile. Text and train positions must remain legible without opening a tooltip.

Step 4 — Preserve individual human and bot actions

SUPERSEDED BY § v0.8.0 (2026-09-09). Read that section, not this one. The collector design here is broadly right and its "Current code findings" were re-verified 2026-09-09, so both are kept — but three things changed: the collector lives in sim/ and is called by LocalSession too rather than being a GameSession private; "Keep player pushes unchanged" below is reversed (that was the open question, and #13 is the answer); and the mechanism now includes foreign-district rendering, pacing by kind, and the behind-counter, none of which are described here.

Current code findings

RE-VERIFIED 2026-09-09. The three findings below still hold, and the insertion point is as cheap as they imply: intent() (src/server/session.ts:361) is synchronous — check → submit → driveBotTurns() → pushesForAll() — and driveBots() (:320) loops submit(). A collector after each submit() is two call sites and needs no async surgery. Two traps that are not in the text below and were found by reading the code rather than the plan:

  • The collector must be inert during replay. resumeSession() rebuilds a game by replaying its whole history through submit(). Wired naively, every server restart re-emits the entire game as display steps and burns nextSequence in display.json. This is the same class of bug as #97 — a mechanism firing on a path nobody pictured it running on.
  • Narration-per-step needs its own high-water mark. sentLines is per-seat and is mutated by linesSince() as a side effect of building a push, so step 2 of "Required changes" cannot read it. Track an independent mark against game.log.length.

GameSession.intent() applies the human intent, runs driveBots(), and only then creates player pushes.

driveBots() can call submit() many times. Player deltas intentionally collapse those moves into one final state, which is appropriate for gameplay but would make bots appear to teleport through several actions on the common board.

Engine events cannot be replayed into display state. One accepted intent may run automatic drain() work and emit several events, and the event list is not a complete reducer.

Required changes

Introduce a display-step collector inside GameSession.

After every successful submit():

  1. Resolve the acting player and current seat.
  2. Produce the sanitized narration lines added by that submission.
  3. Create the next PublicFrame immediately.
  4. Delta it against the last emitted public frame.
  5. Assign the next display sequence.
  6. Emit one DisplayStep.

Apply this both to:

  • the human submission in intent()
  • every successful bot submission inside driveBots()

Capture the frame immediately. Do not retain a mutable GameState reference for later projection because every retained reference would otherwise resolve to the final state.

One display step corresponds to one accepted intent, including automatic consequences drained by that intent. Do not create one step per low-level GameEvent.

Keep player pushes unchanged: players still receive the final coalesced result after all immediately due bots finish.

REVERSED FOR 0.8.0 — RESOLVED 2026-09-09. See § v0.8.0.

The sentence above scoped step 4 to the seatless board. It was the open question of this design and it now has an answer: seated players receive steps too, because #13 is what 0.8.0 is for.

How the three objections that made it an open question were settled:

  • A player waiting to act cannot lag a second behind. Answered by the behind-counter and skip (§ v0.8.0 § 6) rather than by policy — the lag is shown, counted down, and skippable, so the player decides instead of the design guessing.
  • A step sent to a seat is a Frame, and Frames are redacted per seat. Answered by not sending a Frame: public steps animate the board and the existing private Push supplies hand, menu and objective. Zero new redaction surface.
  • Animating other people's turns makes the game feel slower. Answered by dwell by kind — the measured cost is ~40s of animation across a 60-stage game, and bookkeeping dwells at zero.

Player pushes are still coalesced; what changes is that they now also carry steps.

Opening bot moves that occur before any client connects do not need replay. Persist the resulting game state and sequence; a later display receives the final reset.

Failure isolation

Display projection or broadcasting must not invalidate an already accepted game move.

If step generation fails:

  • record a sanitized server diagnostic
  • mark the publisher/display state degraded
  • send a fresh reset on the next successful display update
  • continue normal game and player push processing

Tests

Cover:

  • one human intent with no bot response
  • one human intent followed by several bot intents
  • consecutive bot turns
  • bot pending decisions
  • automatic engine work within one intent
  • ordering of narration and frames
  • sequence continuity
  • player pushes remaining coalesced
  • reconstructed display state matching publicSnapshot() after the final step

Step 5 — Minimal visual-only Jitsi engine

v0.9.0 — DEFERRED (2026-09-09). Steps 5-7 are the Jitsi publisher and are explicitly held off until 0.8.0 ships and Chromium has been measured on phoenix.local. Nothing below has been re-verified against the current code; it was written 2026-08-27. Two things will have moved by the time it is picked up: the control channel is SSE + POST, not a WebSocket (§ THE RELEASE SPLIT), and adding Chromium takes the .s9pk from 63 MB to several hundred against a package that declares no hardwareRequirements today.

Source strategy

Port the smallest relevant production patterns from jitsi-transcription into Station Master with attribution where required. Do not import the sibling repository at runtime, add it as a submodule, or copy its transcription/audio/chat features.

Retain only:

  • Jitsi configuration parsing
  • connection/conference lifecycle
  • lobby handling
  • reconnect classification/backoff
  • generated-display track creation
  • generated-display cleanup and republishing
  • normalized state/error events
  • minimal control protocol

Pin the known working lib-jitsi-meet release:

v2192.0.0+d6f3312f

Do not add ws, and do not add Playwright. The control channel is SSE down + POST up (§ THE RELEASE SPLIT), so it needs nothing beyond node:http, which the server already uses. lib-jitsi-meet is browser-side only: vendor the pinned release into public/ and load it with a <script> tag — there is no bundler in this project, scripts/build-web.ts runs bare tsc. Pin and checksum it; it is a multi-megabyte minified blob entering the repo, which is a decision to take deliberately rather than a build artefact.

Agent page

display-agent.html must:

  1. Validate its opaque session ID and publisher token from the URL fragment.
  2. Connect to /api/display/control.
  3. Register as the engine for exactly one session.
  4. Wait for a supervisor command before joining Jitsi.
  5. Connect to the public display SSE using the supplied view token.
  6. Render the same common-board canvas as the manual page.
  7. Capture the canvas stream at 10 fps.
  8. Join Jitsi and publish it as a desktop video track.
  9. Report normalized lifecycle state over the control channel.
  10. Leave and stop all tracks on supervisor command or pagehide.

Meeting server, room, XMPP configuration, and view token are delivered over the control channel, not placed in query parameters.

Jitsi publishing

Use:

canvas.captureStream(10)
JitsiMeetJS.createLocalTracksFromMediaStreams([{
  mediaType: 'video',
  sourceType: 'generated',
  stream,
  track: stream.getVideoTracks()[0],
  videoType: 'desktop'
}])

Require exactly one generated video track. Dispose any unexpected auxiliary tracks.

Wait for the first video frame before publishing and report a diagnostic if it does not arrive. Do not automatically recycle a healthy track merely because a remote participant initially sees a blank publication; the existing harness observed occasional first-publication blankness on the Jitsi side.

On reconnect:

  • retain the program-owned canvas stream
  • remove/dispose the stale Jitsi local track
  • reconnect and rejoin
  • create a fresh Jitsi wrapper track around a clone of the retained stream
  • republish it

Jitsi initialization and privacy

Initialize with:

JitsiMeetJS.init({
  disableAudioLevels: true,
  enableAnalyticsLogging: false,
  disableThirdPartyRequests: true
});

The pinned library declarations confirm disableThirdPartyRequests is supported.

Do not initialize microphones, cameras, remote audio sinks, chat, TTS, STT, analytics, or rtcstats endpoints.

Before release, capture browser network destinations during a live session and verify that traffic is limited to:

  • Station Master loopback/server endpoints
  • the configured Jitsi deployment and its advertised media infrastructure

Unexpected telemetry destinations fail acceptance.

Meeting states

Handle these explicitly:

  • waiting-for-admission: the participant joined a lobby and is waiting for a moderator.
  • waiting-for-moderator: the guest cannot create the room because no authenticated moderator has opened it.
  • reconnecting: a previously joined publisher lost its connection and is retrying.
  • failed: malformed configuration, exhausted retry budget, unrecoverable Jitsi error, or repeated browser failure.

conference.authenticationRequired may arrive asynchronously as a conference error after the initial join command appears successful. Handle both synchronous and event paths.

For waiting-for-moderator:

  • leave/disconnect the failed attempt
  • retry every 8 seconds
  • stop after JITSI_WAIT_FOR_MODERATOR_SECONDS, default 600
  • immediately continue when a human moderator opens the meeting

For lobby admission, remain connected until admitted, stopped, or the configured join deadline expires.

Tests

Port/adapt the sibling repository’s proven tests for:

  • generated-display track contract
  • lifecycle transitions
  • lobby state
  • asynchronous authenticationRequired
  • reconnect classification
  • generated-display republish
  • cleanup after publish failure
  • stale callbacks from an old connection
  • initialization privacy flags
  • no audio/camera track creation

Step 6 — Chromium publisher supervisor

v0.9.0 — DEFERRED (2026-09-09). Steps 5-7 are the Jitsi publisher and are explicitly held off until 0.8.0 ships and Chromium has been measured on phoenix.local. Nothing below has been re-verified against the current code; it was written 2026-08-27. Two things will have moved by the time it is picked up: the control channel is SSE + POST, not a WebSocket (§ THE RELEASE SPLIT), and adding Chromium takes the .s9pk from 63 MB to several hundred against a package that declares no hardwareRequirements today.

Process model

Create one Chromium child process per published game.

Do not share one Chromium process across games. Per-game processes provide crash isolation, simple lifecycle ownership, and match the production-proven Jitsi Transcription design.

Default publisher capacity to one:

JITSI_MAX_PUBLISHERS=1

Games beyond capacity enter queued in creation order. Make the cap configurable only after measuring CPU and memory on target Station Master hardware.

Browser launch

Find Chromium using:

  1. CHROME_BIN
  2. /usr/bin/chromium
  3. /usr/bin/chromium-browser
  4. /usr/bin/google-chrome

Use a unique temporary user-data directory per publisher and remove it after exit.

Launch with the StartOS-proven flags:

--headless=new
--no-sandbox
--disable-dev-shm-usage
--autoplay-policy=no-user-gesture-required
--use-fake-device-for-media-stream
--use-fake-ui-for-media-stream
--disable-background-timer-throttling
--disable-renderer-backgrounding
--disable-backgrounding-occluded-windows

--no-sandbox is acceptable only inside the existing StartOS container boundary and because Chromium loads the Station Master-owned loopback agent page.

Filter noisy Chromium stderr, but retain messages matching fatal conditions, renderer crashes, out-of-memory errors, and “Aw, Snap”.

Control protocol

Use a versioned, session-keyed JSON protocol over /api/display/control.

Registration includes:

  • protocol version
  • role engine
  • session ID
  • publisher token

The broker must:

  • validate every message
  • limit payloads to 64 KiB
  • disable per-message compression
  • route commands/events only to the registered session
  • allow a newer engine to supersede only the same session
  • reject client/engine role changes on an established socket
  • clear pending commands when an engine disconnects
  • reconnect the agent-side control stream every two seconds until stopped — note that an EventSource does this by itself, which is part of why the transport changed

Public frames remain on SSE and never traverse this control protocol.

Lifecycle and recovery

Supervisor flow:

  1. Allocate capacity.
  2. Spawn Chromium.
  3. Wait for authenticated engine registration.
  4. Send Jitsi join configuration.
  5. Wait for joined/admitted state.
  6. Command generated-display publication.
  7. Monitor process, control socket, and Jitsi state.
  8. Gracefully stop on game completion, server shutdown, or administrative stop.

For unexpected Chromium exit during an active game:

  • mark reconnecting
  • clean the old profile
  • relaunch with delays of 1, 2, 4, 8, and 16 seconds
  • reset the consecutive-failure count after five stable minutes
  • enter failed after five consecutive launch/registration failures
  • keep the underlying game and browser display operational

Graceful teardown

On stop:

  1. Send the engine a leave command.
  2. Wait up to five seconds for confirmation.
  3. Send Chromium SIGTERM.
  4. Wait up to three additional seconds.
  5. Use SIGKILL only if it remains alive.
  6. Remove the temporary profile.
  7. Release publisher capacity.

Clean leave matters because Jitsi may show a stale participant for 30–120 seconds after abrupt termination. The board does not consume remote audio, so these ghosts are cosmetic, but duplicate meeting participants remain undesirable.

Publish the final game state for a five-minute completion grace period, then leave the meeting. Server shutdown bypasses this grace period and leaves immediately.

Tests

Use fake child processes and fake control sockets to test:

  • exact required Chromium flags
  • binary discovery
  • profile isolation and cleanup
  • capacity queue ordering
  • engine registration authentication
  • per-session supersession
  • command timeout and disconnection
  • crash relaunch backoff
  • retry exhaustion
  • graceful leave before termination
  • forced kill fallback
  • publisher failure not affecting the game session

Step 7 — Configuration, lifecycle, packaging, and observability

v0.9.0 — DEFERRED (2026-09-09). Steps 5-7 are the Jitsi publisher and are explicitly held off until 0.8.0 ships and Chromium has been measured on phoenix.local. Nothing below has been re-verified against the current code; it was written 2026-08-27. Two things will have moved by the time it is picked up: the control channel is SSE + POST, not a WebSocket (§ THE RELEASE SPLIT), and adding Chromium takes the .s9pk from 63 MB to several hundred against a package that declares no hardwareRequirements today.

Configuration

Support:

JITSI_SERVER_URL
JITSI_GUEST_DOMAIN
JITSI_SERVICE_URL
JITSI_XMPP_DOMAIN
JITSI_MUC_DOMAIN
JITSI_FORCE_JVB=false
JITSI_MAX_PUBLISHERS=1
JITSI_WAIT_FOR_MODERATOR_SECONDS=600
JITSI_JOIN_TIMEOUT_SECONDS=600
JITSI_FINISHED_GRACE_SECONDS=300
CHROME_BIN

Require JITSI_SERVER_URL to be an HTTP(S) origin without credentials, path, query, or fragment.

Default the XMPP WebSocket to:

wss://<jitsi-host>/xmpp-websocket

Default MUC to conference.<xmpp-domain>. Keep explicit overrides for self-hosted deployments.

If JITSI_SERVER_URL is absent:

  • browser common-board display remains enabled
  • Jitsi meeting URL and publisher are disabled
  • game creation and play remain unaffected

JWT/authenticated publisher support is out of scope for the first version. Authenticated-room deployments rely on a human moderator opening the room and the publisher’s waiting-for-moderator retry.

Session integration

Create display metadata when the lobby starts a game.

Return the meeting and display links to every authenticated player. All players receive the same links.

On server startup:

  • load active game saves
  • load or migrate display metadata
  • rebuild current public snapshots
  • queue publishers only for active games
  • do not automatically republish completed games

On SIGTERM, including StartOS backup shutdown:

  • stop accepting new publisher work
  • command every engine to leave
  • terminate browsers
  • then close HTTP/control services

Health and logs

Health must distinguish:

  • game server availability
  • common display availability
  • Jitsi configuration present
  • Chromium available
  • publishers active/queued/failed

Do not report Jitsi publishing as healthy merely because the supervisor is running.

Use structured logs containing:

  • game/session ID
  • publisher state transition
  • safe Jitsi room identifier
  • browser exit code/signal
  • retry attempt
  • sanitized error category

Exclude all tokens, full control frames, player private state, query strings, and browser profile paths.

Dependencies and packaging

Add runtime dependencies:

  • the pinned lib-jitsi-meet release, vendored as a browser asset rather than an npm runtime import

No Node runtime dependency is added. package.json has no dependencies key and the wrapper's runtime image copies only package.json and src/ — see § THE RELEASE SPLIT. Keep it that way.

Add Chromium and tini to the Station Master runtime image or companion StartOS packaging repository.

Use tini as PID 1 so terminated Chromium children are reaped.

Visual-only Station Master publishing does not require:

  • PulseAudio
  • Xvfb
  • xauth
  • ffmpeg
  • Playwright browsers
  • CDP tooling

Run the service as a non-root application user. Chromium still receives --no-sandbox because the StartOS subcontainer does not grant the kernel capabilities required by Chromium’s internal sandbox.

Do not raise the package RAM requirement based solely on the Jitsi Transcription measurements. Measure the combined Station Master server plus publisher on actual target hardware first.

End-to-end test plan

Automated tests

Run:

  • Station Master typecheck
  • all existing engine/server/web tests
  • public-projection and leak tests
  • display delta/reconstruction tests
  • HTTP/SSE authentication tests
  • renderer lifecycle tests
  • control-protocol tests
  • Jitsi adapter tests with a fake runtime
  • Chromium supervisor tests
  • persistence migration tests

Local integration

With a local or test Jitsi deployment:

  1. Start a two-player game with one bot.
  2. Open the manual display and both player screens.
  3. Confirm human moves update all three.
  4. Confirm bot moves appear as distinct ordered animations.
  5. Join the Jitsi meeting from another browser.
  6. Confirm the Station Master board is published as a desktop share.
  7. Force an XMPP/media disconnect.
  8. Confirm reconnect and display-track republish.
  9. Stop the game/server and confirm the publisher leaves.

Authenticated deployment

On a Jitsi deployment where guests cannot create rooms:

  1. Start the Station Master game before opening the meeting.
  2. Confirm publisher state becomes waiting-for-moderator.
  3. Open the meeting as a moderator.
  4. Confirm the publisher joins on the next retry.
  5. If lobby is enabled, confirm waiting-for-admission until admitted.
  6. Confirm neither state is reported as an immediate terminal failure.

Privacy verification

Capture:

  • raw display SSE
  • rendered canvas screenshots
  • control-channel messages
  • Jitsi network destinations
  • server logs

Verify that none contains:

  • card identities from hands
  • objectives
  • player-only prompts or moves
  • private draw identities
  • seed
  • view/publisher tokens in logs
  • unintended third-party telemetry

Soak and target hardware

Run at least a two-hour Station Master integration soak with:

  • continual public-board updates
  • human and bot moves
  • two forced connection drops
  • one forced Chromium termination
  • clean service shutdown

Record:

  • container RSS
  • Chromium RSS
  • CPU during idle and animation bursts
  • Jitsi reconnect time
  • display queue depth
  • dropped SSE clients
  • stale meeting-participant duration after graceful and forced exits

The sibling repository’s four-hour run validates the underlying headless Jitsi approach, but Station Master still needs this integration-specific measurement.

Acceptance criteria

The feature is complete when:

  • Every player can open one shared browser display.
  • The same display is visible as a desktop share in the game’s Jitsi meeting.
  • Human and bot moves update that board through the same path.
  • Consecutive bot intents remain visibly distinct.
  • A reconnecting display reconstructs the latest state without replay.
  • Jitsi disconnection republishes the existing canvas stream.
  • A guest publisher waits for a moderator rather than failing immediately.
  • Browser/Jitsi failure never prevents normal game play.
  • Shutdown leaves the conference before Chromium termination.
  • No private game information appears in projection, transport, rendering, logs, or Jitsi.
  • No unexpected third-party telemetry connection is observed.
  • Chromium resource use is measured on target hardware before increasing concurrency.

Assumptions and defaults

  • The common board is a separate participant named Station Master — Common Board.
  • It represents the table, never a particular human or bot.
  • Jitsi is configurable and self-hosted; meet.jit.si-specific behavior is not assumed.
  • One publisher process is allowed by default.
  • A human moderator may need to open or admit the publisher.
  • Public display and normal multiplayer remain valuable and operational without Jitsi.
  • The sibling Jitsi Transcription repository is a reference implementation, not a shared runtime package.
  • The untracked Station Master harness remains research material and is not promoted wholesale into production.
  • Audio, transcription, speech, chat, camera capture, player screen sharing, and JWT publisher authentication are out of scope.