SpaceCities / docs /analysis /03-client-coupling.md
Almaatla's picture
Claude Opus 5
docs: final dossier passes correct three tasks and add a real defect
a75dff0 unverified
|
Raw History Blame Contribute Delete
70.7 kB
# 03 — Client coupling: what must change to become a networked client
**Status:** analysis, decisive. Input to `docs/adr/` and `TASKS.md`; not a specification.
**Tree audited:** `/home/user/SpaceCities` @ working tree (the imported-verbatim upstream, ADR-0002).
All `file.js:line` citations are against that tree and are exact at time of writing — cited so a
reader can confirm a claim, not as a patch plan.
**Peers:** [`01-engine-nplayer-seams.md`](01-engine-nplayer-seams.md) owns everything inside
`engine/`; [`02-command-wire-protocol.md`](02-command-wire-protocol.md) owns the envelope, codec and
anti-cheat. This dossier stays on the **client** side of that line: the ~17.9k JS + 1.7k CSS lines
outside `engine/`.
**Architecture assumed (decided):** server-authoritative headless Node sim (ADR-0003); single-player
runs the same path through an in-process loopback transport (ADR-0004).
---
## 0. Headline findings
1. **The client mutates the simulation in exactly 64 places, in 6 files.** 26 `issue*` calls,
38 non-`issue*` engine mutators. Every one already takes an explicit `(state, id, args)`-ish
shape — they are command envelopes that were never serialized. Dossier 02's D1 ("wrap, do not
rewrite") is what makes this a re-seaming job.
2. **Only two client lines write a simulation field directly, bypassing `engine/` entirely** —
`hudSelection.js:1045` (`e.homeCC = null`) and `hudSelection.js:1722` (`e.electrified = v`).
Neither goes through `engine/commands.js` today. The first is nonetheless free to fix:
`issueSetHomeBase(units, ccId)` already exists (`engine/commands.js:281`) and accepts `null`, so
the HUD is bypassing a command that would have done the job. The second is a genuine gap —
**`electrified` has two direct writers and zero commands**, the HUD and `engine/aiIndustry.js:172`
(`engine/industry.js:38` documents the duplication outright). These are the only two holes in an
otherwise correctly-placed trust boundary, and closing them is the single most actionable item
in this document.
3. **No client file writes `resources` or `credits`.** Verified by grep: every economic mutation
goes through an engine function. The trust boundary is already drawn in the right place; it is
simply not enforced. What the client *does* do is **read** the local seat's economy —
`state.players.player.resources` at 7 sites in `hudSelection.js` alone, 15 across the client —
a read that per-seat filtering must keep serving (it always can: your own economy is never fogged).
4. **The renderers are already owner-generic where it counts.** Team colour is read as
`state.players[owner].color` at all four draw sites (`renderUnits.js:66`, `renderBuildings.js:75`,
`minimap.js:95`, `minimap.js:100`); `#4fd1ff`/`#f87171` live in `engine/state.js:179-180` as
*data*, and every client occurrence of those literals is unrelated UI chrome. The fog rule is
factored into one named predicate, `renderShared.js:253 hiddenByFog`. "Seat 3 of 5" does not
break rendering — and neither does fog filtering: the frame interpolator is **already** safe
against entities appearing and vanishing between snapshots (§4.2 item 6), which is normally the
first thing that breaks when a client stops holding the whole world.
5. **What "seat 3 of 5" actually breaks is 119 owner-literal sites outside `engine/`** — 68
comparisons, 29 owner arguments passed to already-parameterized engine functions, and 22
hardcoded `state.players.player`/`.ai` property paths. (Counting `tools/` and `competition.js`
too, the comparison total is 80, matching dossier 01's finding #5.) **110 of the 119 are the same
assertion — "`player` means me" — and collapse to one `state.localOwner` seam.** The remaining
**9** are the harder assertion, "the enemy is `ai`": genuine 1v1 semantics that must be
redesigned or scoped out. §3.2 lists all nine.
6. **Broadcasting full state is not affordable, and the numbers are not close.** Measured on this
machine with `engine/persist.js`: a 4× map with 800-vs-800 units serializes to **404 KB in
4.45 ms**; at 20 Hz × 4 seats that is 32 MB/s and 18 ms of a 50 ms tick budget spent on
`JSON.stringify` alone. A per-seat fog-filtered delta of the same state is **55 KB in 0.69 ms**.
Dossier 00's "the sim runs ~500× faster than real time" is confirmed from the other side:
**serialization is the server cost, and filtering is what makes it affordable, not what makes it
expensive.** Recommendation: **fog-filtered per client, from day one.** §4.
7. **The map never has to cross the wire.** `serializeGame` does not include `state.map` at all
(measured: `map` is absent from the payload's top-level keys) — terrain, node positions and base
positions are regenerated from `seed` + `planetId` on load (`engine/persist.js` `rehydratePlanet`).
A client that is told `{planetId, seed, sizeMult, resourceMult}` at join reconstructs the entire
static world locally. That is the largest single replication saving available and it is already
built.
8. **~45% of the client is verbatim or a one-line-per-site substitution.** Counting the modules that
ship in multiplayer v1, **68% of the in-scope 12,856 LOC needs zero or near-zero change**. The
real work is concentrated in four files totalling 3,662 LOC — `boot.js`, `hudSelection.js`,
`input.js`, `inputCommands.js` — and inside three of those four the touched lines are under 15%
of the file. §5.
---
## 1. The local-authority inventory
Every place outside `engine/` that mutates simulation state, or assumes it owns the simulation.
### 1.1 `engine/commands.js` `issue*` — direct order issuance (26 sites, 4 files)
Imports: `inputCommands.js:27`, `hudSelection.js:21`, `main.js:21`, `input.js:12`.
| # | Site | Call | Notes for the codec |
|---|---|---|---|
| 1 | `inputCommands.js:94` | `issueAttackMove(combatants, x, y, queue, formation)` | right-click; split from :95 by role |
| 2 | `inputCommands.js:95` | `issueMove(others, x, y, queue, formation)` | same click, second envelope — see §1.6 |
| 3 | `inputCommands.js:199` | `issueSetRally(building, p.x, p.y, node?.id)` | **the one engine signature change** (02 D2): bare `Building` ref, no `state` |
| 4 | `inputCommands.js:212` | `issueAssistBuild(workers, target.id, target.type, queue)` | `target.type` is client-supplied — 02 flags it |
| 5 | `inputCommands.js:226` | `issueServiceBuilding(workers, target.id, queue)` | |
| 6 | `inputCommands.js:235` | `issueRepair(workers, target.id, queue)` | building target |
| 7 | `inputCommands.js:245` | `issueSetHomeBase(eligible, target.id)` | |
| 8 | `inputCommands.js:249` | `issueAttack(attackers, target.id, queue)` | |
| 9 | `inputCommands.js:259` | `issueFerryFreighter(workers, target.id, queue)` | |
| 10 | `inputCommands.js:266` | `issueRepair(workers, target.id, queue)` | unit target |
| 11 | `inputCommands.js:273` | `issueEscort(escorts, target.id, queue)` | **array order is the ring slot** (02 D4) |
| 12 | `inputCommands.js:278` | `issueGather(workers, node.id, queue)` | |
| 13 | `inputCommands.js:282` | `issueMove(selected, p.x, p.y, false, formation)` | |
| 14 | `hudSelection.js:518` | `issueSetAILogistics([f], !on, state)` | reads `state` for the tech gate |
| 15 | `hudSelection.js:542` | `issueSetCollectPoint([f], !on)` | |
| 16 | `hudSelection.js:600` | `issueSetLogiPriority(state, b.id, …)` | already id-based |
| 17 | `hudSelection.js:1821` | `issueCancelRecycle(recyclingNow)` | |
| 18 | `hudSelection.js:1835` | `issueRecycle(recyclable)` | banks resources later (02 #17) |
| 19 | `main.js:149` | `issueAttackMove(combatants, world.x, world.y)` | minimap right-click |
| 20 | `main.js:150` | `issueMove(others, world.x, world.y)` | same click |
| 21 | `input.js:346` | `issueStop(selectedUnits())` | |
| 22 | `input.js:351` | `issueScout(selectedUnits())` | |
| 23 | `input.js:355` | `issueHold(selectedUnits())` | |
| 24 | `input.js:372` | `issuePatrol([u], points)` | **unbounded `points`** (02 #20) |
| 25 | `input.js:379` | `issueHoldFormation(selectedUnits(), shape, leaderPos)` | anchor is the live centroid |
| 26 | `input.js:521` | `issueBuild(state, worker.id, type, p.x, p.y)` | **return value consumed** — §1.6 |
### 1.2 Non-`issue*` engine mutators called from the client (38 sites, 3 files)
Dossier 02's scope correction is confirmed and this is where the money is: **every cost-bearing
action** (production, research, market, diplomacy, colony, galaxy) goes through this group, not
through `commands.js`.
| Family | Sites | Engine module |
|---|---|---|
| Production / research | `hudSelection.js:197` `cancelProduction`, `:235` `cancelResearch`, `:1098`, `:1104`, `:1198`, `:1629` `queueProduction`, `:1149` `researchUpgrade`, `:1179` `researchTech` | `production.js`, `techtree.js` |
| Market (spends credits) | `hudSelection.js:381`, `:395`, `:405` `sell`, `:414` `buy` | `market.js` |
| Freight | `hudSelection.js:449`, `:485` `loadFreighter`; `:459`, `:494` `unloadFreighter` | `galaxy.js` |
| Diplomacy | `hudSelection.js:630` `offerTribute`, `:661` `fulfillRequest`, `:705` `offerGift` | `diplomacy.js` |
| Lanes | `hudSelection.js:766` `unassignShipFromLane`, `:777` `assignShipToLane`, `:781` `deleteLane`, `:793` `createLane` | `galaxy.js` |
| Colony policy | `hudSelection.js:831`, `:837`, `:850`, `:870`, `:876`; `starmap.js:229`, `:239`, `:244` `setColonyPolicy` | `colonyPolicy.js` |
| Colony / capital | `hudSelection.js:900` `upgradeToCapital`, `:905` `packCommandCenter`, `:1750` `deployColonyShip` | `galaxy.js`, `colony.js` |
| Spaceport | `hudSelection.js:1386` `upgradeSpaceport` | `galaxy.js` |
| Bomb | `hudSelection.js:1455` `lightFuse(state, bomb)` | `bomb.js` — takes a live object |
| Scenario | `hud.js:37` `repairConvoy(game.state)`, `hud.js:38` `departNow(game.state)` | `scenarios.js` |
**Two of these are Odyssey/galaxy-only** (lanes, colony policy, freight, spaceport, capital) and
therefore out of multiplayer v1 by §7 — which removes 22 of the 38 from the v1 codec surface.
### 1.3 Direct `state.*` writes from the client
There are exactly four kinds, and they are not all the same severity.
| Kind | Sites | Verdict |
|---|---|---|
| `state.selection = …` | `inputCommands.js:147`, `:152`, `:154`, `:157`, `:177`; `input.js:318`, `:337`, `:382`, `:395`, `:411`, `:543` (**11**) | **Not sim state.** `engine/persist.js` resets it to `[]` on load and dossier 02 D6 moves it to the client session. Zero wire cost. |
| `state.events.length = 0` | `boot.js:766` | **The client drains the sim's event queue.** In multiplayer, events must be produced server-side, fog-filtered per seat (`boot.js:675` already applies the fog rule), replicated, and drained *client-locally*. See §4.2. |
| Galaxy notification drains | `boot.js:511` `pacifyNotes.length = 0`, `:517` `milestones.length = 0`, `:522` `reliefNote = false` | Odyssey-only ⇒ out of v1 (§7). Same pattern as the event drain. |
| **Raw entity-field writes** | `hudSelection.js:1045` `e.homeCC = null`; `hudSelection.js:1722` `e.electrified = v` | **The only true trust-boundary holes.** Neither goes through `engine/commands.js`. `:1045` is free to fix — `issueSetHomeBase(units, ccId)` (`engine/commands.js:281`) already accepts `null`. `:1722` needs a new command. Fix both before the codec ships. |
`hudSelection.js:1722` is the worse of the two, for two reasons. It writes `electrified` on
**every** building in the current selection, with only a client-side `e.owner === "player"` filter
standing between it and electrifying an opponent's Habitat — and under server authority that filter
disappears along with the client that enforced it. And `electrified` has **no command anywhere**:
`engine/aiIndustry.js:172` sets it directly too, which `engine/industry.js:38` documents as
deliberate (*"engine/aiIndustry.js, which flips `electrified` on its buildings the same way the HUD
does"*). A new `issueSetElectrified(state, buildingIds, on)` should become the single writer, with
the AI routed through it as well.
### 1.4 `state.players.player.…` — direct owner-keyed reads (22 sites + 1 optional-chained)
No writes; all reads. They matter because each is a place that must be re-pointed at the local seat
*and* must survive fog filtering (the local seat's own economy is always fully visible, so filtering
never strips these — but they hard-code the key).
- **`state.players.player.resources`** — `hudSelection.js:272`, `:363`, `:432`, `:522`, `:651`,
`:680`, `:1955`; `hudPanelSignature.js:146`, `:227`, `:228`, `:264`, `:272`, `:344`;
`hud.js:99`, `:174`
- **`.upgrades`** — `hudSelection.js:508`, `:1122`, `:1157`; `hudPanelSignature.js:180`
- **`.color`** — `hudSelection.js:1941`; `landingPicker.js:149` (optional-chained:
`dest.players?.player?.color || "#4fd1ff"` — the one place a hardcoded owner *and* a hardcoded
colour appear on the same line; Odyssey-only, out of v1)
- **`.faction`** — `overlays.js:34` (`st.players.player.faction` **and** `st.players.ai.faction` in
one line — a 1v1 assumption, not just a local-seat one)
- **Already owner-generic** — `techChart.js:138`, `:153`, `:196` via `const OWNER = "player"`
(`techChart.js:36`); `hudPanelSignature.js:373` via `state.players[b.owner]`;
`minimap.js:95`, `:100`, `renderUnits.js:66`, `renderBuildings.js:75` via `state.players[e.owner]`
- **Enemy-keyed** — `observer.js:276` `state.players.ai`
> **Trap.** `data.js:124` defines a commodity whose id is literally `"ai"`
> (`{ id: "aifab", out: "ai", … }` — AI cores). `hudPanelSignature.js:272` reads
> `state.players.player.resources.ai`. **A blind rename of the owner id `"ai"` will corrupt the
> economy.** Any owner-literal sweep must be site-by-site.
### 1.5 `createGameState` / `tick` / `createLoop` — the sim ownership itself
All in `boot.js`. This is the part that genuinely moves to the server.
| Site | Call | Moves to |
|---|---|---|
| `boot.js:119` | `createGameState({planetId, seed, rng, aiApm, …})` (skirmish) | server, from the lobby's agreed config |
| `boot.js:169` | `createGameState(…)` (competition fixture) | out of v1 (§7) |
| `boot.js:207` | `createSelfPlayState(…)` (watched duel) | out of v1 / server-side spectate |
| `boot.js:236`, `:244`, `:252` | `setupEscort` / `setupRaider` / `setupBounty` | server (single-player scenarios via loopback) |
| `boot.js:262` | `createGalaxy(…)` | out of v1 (§7) |
| `boot.js:287` | `jumpCapital(game.galaxy, destId, …)` | out of v1 |
| `boot.js:344` | `surrenderGalaxy(game.galaxy)` | out of v1 |
| `boot.js:485` | `createLoop({hz, speed, update, render})` | **splits.** The *sim* half (`update`) moves to the server; the *render* half (`render`) stays and becomes a plain `requestAnimationFrame` interpolator |
| `boot.js:503-504` | `stepGalaxy` / `sweepColonies` | out of v1 |
| `boot.js:530` | `tickSelfPlay(game.state, dt)` | out of v1 |
| `boot.js:531` | `tick(game.state, dt)` | **server** |
`engine/loop.js` is a fixed-timestep accumulator with a render callback and an `alpha` interpolation
fraction. Server-side it keeps the accumulator and drops the render callback; client-side the render
callback survives essentially unchanged because `render.js` already interpolates between two
positions per entity (`snapshotPositions`, `boot.js:501`) — the exact mechanism a networked client
needs for snapshot interpolation. **That is a significant unplanned gift.**
### 1.6 Return values the client consumes — the optimistic-update hazards
Server authority makes every mutation asynchronous. These sites read a synchronous answer *now*:
| Site | Pattern | Why it breaks |
|---|---|---|
| `input.js:521-523` | `const built = worker && issueBuild(...); if (built) buildMode = null; else sound.playProductionBlocked();` | Build mode exits (and the ghost clears) **only on a successful placement**. The client must either predict the placement locally and roll back on a server NACK, or keep the ghost up until an ack — a visible latency change either way. This is the highest-visibility UX consequence in the whole port. |
| `hudSelection.js:449`, `:459`, `:485`, `:494` | `if (loadFreighter(...) > 0) any = true` | Loop over commodities, branching on how much actually moved. Out of v1 with Odyssey, but the pattern recurs. |
| `hud.js:37` | `if (repairConvoy(game.state)) renderHUD()` | Repaints only if the repair took. |
| `boot.js:287-290` | `const result = jumpCapital(...); if (!result) return null; focusActivePlanet();` | Out of v1. |
| `inputCommands.js` (throughout) | each handler `return`s `true`/`false` to mean "this click was consumed" | **Benign.** The consumption decision is a *client-side hit test*, not a sim answer; it can stay synchronous. |
Everything else fires and forgets, followed by `renderHUD()` — which under authority just becomes
"repaint on the next snapshot", i.e. strictly simpler.
---
## 2. `session.js` / `boot.js` — the lifecycle, and how it re-shapes
### 2.1 `session.js` today (120 LOC, zero imports)
A single exported mutable object, `game`, holding 16 fields. It has **no imports at all** and
contains no logic — it exists so `hud`/`boot`/`save`/`input` all see the same current game across a
restart, and everything reads it *at call time* rather than destructuring at module scope
(`session.js:8-10`). That discipline is why a networked rewire is cheap: swapping what `game.state`
points at is already the supported operation.
Fields, grouped by what happens to them:
| Group | Fields | Fate |
|---|---|---|
| **The sim handles** | `state` (`:16`), `galaxy` (`:21`) | `state` becomes a **replicated view**, not the authority. `galaxy` out of v1. |
| **Local input/UI** | `input` (`:17`), `formation` (`:75`), `collapsedSections` (`:84`), `groups` (`:69`) | **Verbatim.** All already documented as "never part of the deterministic sim". |
| **Cross-module UI bookkeeping** | `supplyBlockedUntil` (`:34`), `lastAttackAt` (`:40`), `lastGateCharge` (`:52`), `colonyAlerts` (`:64`) | Verbatim; driven by replicated events instead of local ones. |
| **Mode flags** | `competition` (`:30`), `spectateMatch` (`:114`), `spectateSpeed` (`:119`) | Out of v1 (§7). |
| **Observer** | `observerMode` (`:89`), `spectateId` (`:92`), `spectatePreview` (`:99`), `observerCamera` (`:103`) | **Kept and repurposed** (§7). |
**The re-shape is additive.** `session.js` gains a small, well-defined set of new fields and loses
nothing that v1 ships:
```js
// new in session.js
transport: null, // LoopbackTransport | WebSocketTransport (ADR-0004)
match: null, // { matchId, seats:[{owner,name,kind,color}], mapCfg, hostSeat }
localOwner: null, // MY seat id — the single seam §3 collapses into
serverTick: 0, // last applied authoritative tick
lobby: null, // pre-match lobby model (setup.js renders it)
netStatus: "offline", // offline | connecting | live | lagging | resyncing | dropped
```
`localOwner` is the load-bearing one. `state.localOwner` (or `game.localOwner`) is what turns 110 of
the 119 owner-literal sites (§3) into one substitution.
### 2.2 `boot.js` today — the lifecycle, one line each
- **Splash → map select.** `main.js:165` calls `renderMapSelect()` at module-eval time. `setup.js`
owns the screen; card click → `startGame(planetId)` (or a scenario / Odyssey / Continue variant).
- **`startGame(planetId)`** — `boot.js:109`. Draws a seed (`resolveSeed`, `:64`), resolves difficulty
dials (`:54`), picks the AI's faction from the world archetype (`:118`), calls
`createGameState` (`:119`), hands off to `bootState(fresh, {intro:true})` (`:124`).
- **`bootState(newState, {intro, selfPlay})`** — `boot.js:443`. **The single funnel every start path
uses.** In order: stop the old loop (`:444`), destroy old input (`:445`), exit observer mode
(`:446`), hide map-select / game-over / under-attack (`:447-450`), clear mode flags and per-game UI
(`:453-457`), **assign `game.state`** (`:458`), toggle the scenario body class (`:462`), show
seed + faction chips (`:463-464`), objectives strip (`:465`), `attachInput` (`:466`), park the
camera on the player's base (`:471-475`), reset effects/facing/panel-signature/world bookkeeping
(`:476-481`), clear pause (`:482`), **`createLoop`** (`:485`), `loop.start()` (`:581`),
`renderHUD()` (`:582`).
- **The loop's `update`** — `boot.js:499-532`. Skip if paused (`:500`); `snapshotPositions` (`:501`);
then one of three branches: galaxy (`:502-524`), spectated self-play (`:525-530`), or plain
`tick(game.state, dt)` (`:531`).
- **The loop's `render`** — `boot.js:533-579`. `tickCamera`; pin `alpha` to 1 while paused (`:540`);
choose `viewState`/`viewCamera` (observer or live, `:545-546`); `drawFrame` (`:547`);
`drawMinimap` (`:548`); `processFrameEvents()` (`:549`); throttled `renderHUD` every 150 ms
(`:550`); **then the over-poll** (`:551`).
- **Win / lose.** `boot.js:551`: `if (game.state.over && !announced)` → `announced = true`,
`loop.stop()` (`:553`), then `showScenarioEnd` or `showGameOver(winner, seed, restartToMapSelect,
{…})` (`:560-577`). The client **polls a boolean the sim set** — it does not decide.
- **Restart.** `restartToMapSelect()` — `boot.js:400`. Stop loop, destroy input, exit observer,
null `state`/`galaxy`/`competition`/`spectateMatch`, repaint observer UI, clear pause, hide the
pause button, `renderMapSelect()`, unhide map-select. Documented as idempotent (`:399`).
- **Pause.** Refcounted reasons (`boot.js:94-107`) — Help, the Home-confirm modal, the landing
picker, and a manual `P`. **Gates `update()` only; render keeps drawing.**
### 2.3 The multiplayer re-shape, precisely
The shape of `bootState` is exactly right and should be preserved. What changes is **who calls it,
with what, and who decides it is over**.
| Today | Networked |
|---|---|
| `renderMapSelect()` at `main.js:165` | unchanged — the splash is still local |
| Card click → `startGame(planetId)` | Card click → `createMatch(cfg)` / `joinMatch(code)` → **lobby screen** |
| — | **`renderLobby()`** (new, in `setup.js`): seat list, per-seat ready flags, kind (human / AI / MCP agent), colour, faction, host-only map dials. Driven by a replicated `lobby` snapshot; the host's dial changes are commands like any other. |
| `resolveSeed(setup)` at `boot.js:114` — client draws the seed with `Math.random` | **server draws the seed** and announces it in the match-start message. The client's `resolveSeed` survives for single-player-over-loopback only. |
| `createGameState(…)` at `boot.js:119` | **server** constructs it from the lobby config; the client receives `{matchId, seats, planetId, seed, sizeMult, resourceMult, matchTimeLimit, popCap, swapAsym}` and rebuilds the *static* world locally (§0.7) |
| `bootState(fresh, {intro:true})` | `bootState(view, {intro, seats, localOwner})` — **same function**, called from the transport's `match-start` handler instead of from a click handler. Everything from `:444` to `:482` is unchanged. |
| `loop = createLoop({update: dt => tick(state,dt), render})` | `createLoop({update: **applyPendingSnapshot + advanceInterpolation**, render})`. The render half at `:533-579` is untouched. `hz` becomes the snapshot cadence, not the sim rate. |
| `snapshotPositions(game.state)` at `:501` | **unchanged and now load-bearing** — it is already the interpolation baseline; it becomes the previous *authoritative* snapshot. |
| `processFrameEvents()` at `:549`, draining `state.events` | drains the **replicated, per-seat-filtered** event list the snapshot carried |
| `if (game.state.over && !announced)` at `:551` | **replaced by a server `match-end` message.** Polling a local boolean is wrong under authority: a client whose connection dropped would poll a stale `over:false` forever, and a modified client could set `over` to skip the screen. The `showGameOver(...)` call at `:561` keeps its exact argument shape — `winner`, `seed`, `winReason`, `state` all arrive in the message. |
| `restartToMapSelect()` at `:400` | keeps its whole teardown, **plus** `transport.leave(matchId)` and `game.match = game.localOwner = null`. Its idempotence (`:399`) is exactly what a "server ended the match while you were in the Home dialog" race needs. |
| Refcounted pause (`:94-107`) | **local pause is gone in multiplayer** — one player cannot stop four others' clocks. `pauseLoop`/`resumeLoop` survive as *render-side* modal gating (Help, Home-confirm keep working), but must no longer gate `update`. Add a `pauseReasons` guard: in a networked match the sim never pauses; the overlay just goes up. Server-side pause (host-initiated, all-seat) is a v2 feature. |
| `game.spectateSpeed` / `speed:` at `:495` | ignored in multiplayer — the server owns the clock. |
**Four new lifecycle states** that do not exist today, each needing a screen or a banner:
1. **Lobby** — between map-select and `bootState`. New; ~150–250 LOC in `setup.js`.
2. **Waiting for seats / countdown** — trivial, lives in the lobby screen.
3. **Reconnect** — the transport drops; the client must show a banner and either resync from a full
snapshot or fall out to map-select. `game.netStatus` drives a CSS class, exactly like
`document.body.classList.toggle("paused", …)` at `boot.js:97`.
4. **Someone else left / was eliminated** — an in-match event with no analogue today (`state.over`
is binary). Needs a toast (`showGalaxyToast` already exists, `overlays.js`) and a seat-list
update.
### 2.4 What must not move
- **`main.js`'s side-effect imports** (`main.js:26-28`: `starmap.js`, `techChart.js`, `update.js`)
self-wire buttons and hotkeys at module-load time. Dropping one silently disables a feature. If
`starmap.js` is scoped out of v1 (§7), the *import must stay* with the module stubbed, or the
`M` key handler and the galaxy button go missing without an error.
- **`session.js`'s read-at-call-time discipline** (`session.js:8-10`). Every networked rewire depends
on being able to swap `game.state` under running consumers.
- **`bootState` as the single funnel.** Adding a second boot path for multiplayer is the exact
mistake ADR-0004 rejects at the transport layer, one level up.
---
## 3. "You are the player" assumptions
Three counted forms, **119 sites** in the shipping client:
| Form | Count | Distribution |
|---|---|---|
| **Comparisons** — `owner === "player"`, `!== "ai"` | **68** | `inputCommands.js` 16 · `hudSelection.js` 7 · `renderBuildings.js` 6 · `hud.js` 6 · `boot.js` 6 · `input.js` 5 · `renderEffects.js` 4 · `hudPanelSignature.js` 4 · `landingPicker.js` 4 · `minimap.js` 2 · `overlays.js` 2 · `renderShared.js` 2 · `starmap.js` 2 · `renderUnits.js` 1 · `observerPanel.js` 1 |
| **Owner arguments** — `prereqsMet(state,"player",…)`, `supplyUsed(state,"ai")` | **29** | `overlays.js` 12 · `hudSelection.js` 10 · `hud.js` 3 · `observer.js` 2 · `renderEffects.js` 1 · `techChart.js` 1 |
| **Property paths** — `state.players.player.resources` | **22** | `hudSelection.js` 11 · `hudPanelSignature.js` 7 · `hud.js` 2 · `observer.js` 1 · `overlays.js` 1 (+ `landingPicker.js:149`, optional-chained) |
Adding `tools/ailab.js` (9), `tools/duelCore.js` (1) and `competition.js` (2) brings the comparison
total to the **80** dossier 01's finding #5 reports.
The **owner-argument** form is the cheapest of the three and worth calling out: those 29 sites call
engine functions that are *already* owner-parameterized (`prereqsMet`, `countUnits`,
`hasCompletedBuilding`, `supplyUsed`/`supplyCap`, `powerCap`/`powerDraw`, `playerScore`,
`scoreBreakdown`, `commodityAvailable`, `committedDoctrine`, `recycleFrac`, `powerEfficiency`). The
engine is ready; only the caller hardcodes the seat.
Across all three forms the sites are **three different assertions** with three different costs.
### 3.1 "Is this entity mine?" — the large, easy class
The dominant pattern. Mechanical substitution: `"player"` → `state.localOwner`.
| File | Sites |
|---|---|
| `inputCommands.js` | `:58`, `:62` (fog gate on hit-test), `:99` (`selectedUnits`), `:131`, `:136` (box select), `:170`, `:178` (double-click type select), `:197`, `:210`, `:223`, `:232`, `:242`, `:247`, `:257`, `:264`, `:271` (right-click target dispatch) |
| `input.js` | `:317` (click select), `:383` (select-all-army), `:391` (idle worker), `:407` (idle producer), `:423` (CC cycle) |
| `hudSelection.js` | `:773`, `:789`, `:898`, `:1076`, `:1696`, `:1713`, `:1722` |
| `hudPanelSignature.js` | `:149`, `:234`, `:290`, `:312` |
| `hud.js` | `:47`, `:137`, `:194`, `:336`, `:352`, `:429` |
| `renderBuildings.js` | `:84` (enemy pip), `:120`, `:147`, `:165`, `:181`, `:233` (own-only overlays: rally lines, storage bars, jump staging, power grid) |
| `renderEffects.js` | `:413`, `:419`, `:563`, `:600`, `:637` |
| `renderUnits.js` | `:91` (enemy pip) |
| `renderShared.js` | `:254` (`hiddenByFog` — **the single fog predicate**) |
| `minimap.js` | `:94`, `:99` |
| `overlays.js` | `:68`, `:69`, `:71`, `:72`, `:73`, `:74`, `:78`, `:79`, `:80`, `:81` (tutorial objectives, via `countUnits(state,"player",…)` / `hasCompletedBuilding(state,"player",…)`) |
| `boot.js` | `:370` (camera to my CC), `:675` (event fog gate), `:737` (my supply block) |
| `starmap.js` | `:56`, `:58` |
| `landingPicker.js` | `:60`, `:61`, `:151`, `:160` |
| `techChart.js` | `:36` (`const OWNER = "player"` — **already one seam**) |
**110 of the 119 sites are this class. Cost: one line each, plus one place to set
`state.localOwner`.** `techChart.js:36` is the pattern to copy everywhere — name the seam once per
module, then read the name — and `hudPanelSignature.js:373` (`state.players[b.owner].upgrades`)
shows the property-path form done right.
**Do not sweep with `sed`.** `data.js:124` defines a commodity whose id is `"ai"` (AI cores), read as
`state.players.player.resources.ai` at `hudPanelSignature.js:272`. Site-by-site, or the economy
breaks silently.
### 3.2 "The enemy is `ai`" — the class that genuinely breaks at N seats
Nine sites. These do not have a mechanical fix; each needs a design answer.
| Site | Code | What breaks at 5 seats |
|---|---|---|
| `hud.js:314` | `const you = playerScore(state,"player"), foe = playerScore(state,"ai")` | The score chip shows **one** opponent. Needs a leaderboard, or "you vs. best rival". |
| `overlays.js:34` | `FACTIONS[st.players.player.faction]`, `FACTIONS[st.players.ai.faction]` | The faction chip is a two-sided "you vs them" line. |
| `overlays.js:376` | `winner === "player" ? playVictory() : playDefeat()` | Every non-winner hears defeat, including seats that placed 2nd of 5 — arguably fine, but it is a decision. |
| `overlays.js:392-394` | `winner === "player" ? "Victory — the enemy's last Command Center is destroyed." : "Defeat — your last Command Center was destroyed."` | "**the** enemy" is false with 4 opponents. Needs `winner === state.localOwner` plus a named-winner string. |
| `overlays.js:406-407` | `scoreBreakdown(opts.state,"player")`, `playerScore(opts.state,"ai")` | "Your score / Enemy score" — needs N rows. |
| `boot.js:684`, `:704`, `:712` | `if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y)` | **A live defect at N seats.** The header comment at `boot.js:669-671` states the assumption outright: *"An attackHit whose attacker is the AI necessarily means the target is the player's (only two sides exist)."* With 5 seats, seat 2 shelling seat 4 in your line of sight fires **your** under-attack alarm. Correct predicate: `ev.targetOwner === state.localOwner`. This requires the engine event to carry the target's owner — a change on the **engine** side (dossier 01's surface), flagged here because the client is where the bug is visible. |
| `observer.js:290-291` | `supplyUsed(state,"ai")`, `supplyCap(state,"ai")` | The observer's "what the neighbour has" panel is single-opponent by construction. |
| `observerPanel.js:89` | `owner === "player" ? watch.aName : watch.bName` | Two-entrant naming; out of v1 with competitions (§7). |
| `observer.js:116` | `state.map.bases.player \|\| state.map.bases.ai` | Free-camera home point; see §3.5. |
### 3.3 Colours
**Not a problem.** `#4fd1ff` and `#f87171` are owner colours **only** in `engine/state.js:179-180`,
as `ownerDefs` data. Every client occurrence of those hex strings is unrelated chrome:
- `style.css:13` `--accent: #4fd1ff`, `style.css:16` `--bad: #f87171` — UI theme tokens
- `renderEffects.js:111`, `:113` — per-unit-type weapon tracer colours (`lancer` beam,
`dreadnought` bolt), not team colours
- `renderEffects.js:58`, `:315`, `:326`, `:447`, `:511`, `:542`, `:584`, `:616`, `:680` — ghost
validity, waypoint lines, rally lines, escort rings
- `renderShared.js:195`, `renderBuildings.js:109`, `:154`, `:197`, `minimap.js:114`,
`renderUnits.js:126` — health-bar / power-tier / concern-badge palettes
- `landingPicker.js:149` — `dest.players?.player?.color || "#4fd1ff"` — **the one real coupling**:
a hardcoded fallback plus a hardcoded owner key. Odyssey-only, out of v1.
Every actual entity draw reads `state.players[owner].color`: `renderUnits.js:66`,
`renderBuildings.js:75`, `minimap.js:95`, `minimap.js:100`. **Adding seats 3–8 needs new entries in
`engine/state.js`'s `ownerDefs` and nothing in the renderers.** Two caveats: pick a palette that is
distinguishable at minimap-dot size (4–5 px) and colour-blind-safe, and decide whether colour is
per-seat (absolute) or per-viewer (you are always blue) — the latter is friendlier and costs one
remap at `bootState`, but breaks screenshots and spectating.
### 3.4 `state.fog` vs `state.fogAI`
The client reads `state.fog` at **9 sites** and never `state.fogAI`:
`inputCommands.js:58`, `:62`, `:69`; `renderEffects.js:466`; `boot.js:675`; `render.js:202`;
`minimap.js:36`, `:83`; `renderShared.js:254`; `renderNodes.js:26`.
`engine/state.js:232-233` makes `state.fog`/`state.fogAI` **aliases** into `state.fogs`. So the
obvious client fix — "point `state.fog` at *my* seat's fog and every reader keeps working" — is a
one-line change that makes all 9 sites correct at once.
> ⚠️ **Do not do this before dossier 01's finding #6 is fixed.** `engine/gather.js:64` and
> `engine/scout.js:42` read `unit.owner === "player" ? state.fog : state.fogAI`. Rebinding the alias
> client-side is harmless *on a client that no longer simulates* — but the same client code path runs
> the loopback single-player server (ADR-0004), where it would silently resolve the wrong fog and
> desync. **Sequence: fix the engine, then rebind.**
Under the recommended fog-filtered replication (§4), the client is only ever *sent* one fog grid —
its own — so `state.fog` naturally is the local seat's fog and `state.fogAI` should not exist on a
client state at all. Assert that in a test.
### 3.5 `state.map.bases.player`
`boot.js:371` and `boot.js:471` open the camera at `state.map.bases.player`; `input.js:430` uses it
as the fallback for the CC-cycle key; `observer.js:116` falls back to `bases.player || bases.ai`.
`engine/map.js` builds `bases` as a 2-entry left/right mirror (dossier 01 finding #3). N-seat start
positions are an **engine/map** change; the client needs `state.map.bases[state.localOwner]` and
nothing more.
### 3.6 The "seat 3 of 5" test, summarized
| Layer | Verdict |
|---|---|
| Renderers, minimap, colours | **Already works.** Owner-generic reads throughout. |
| Selection, hit-testing, HUD panels, objectives | **Works after one substitution per site** (~71 sites). |
| Fog | **Works after one alias rebind**, once the engine's two `=== "player"` fog reads are fixed. |
| Under-attack alarm | **Broken** — fires on any `owner === "ai"` hit. Needs a target-owner field on the event. |
| Score chip, faction chip, victory copy, score breakdown | **Broken** — hardcoded two-sided copy. ~6 sites, all in `hud.js`/`overlays.js`, all cosmetic-but-visible. |
| Start positions | **Engine change** (`map.bases`), then one client substitution. |
---
## 4. Rendering under authoritative fog
### 4.1 How hard is per-client filtering, given `engine/fog.js`?
**Easy — the primitive already exists and is already the right shape.** `engine/fog.js` is 144 lines.
Fog is a coarse grid (`FOG_CELL_SIZE = 40`, `fog.js:23`) of two `Uint8Array`s per owner —
`visible` (recomputed from scratch every tick, `fog.js:125`) and `explored` (permanent). Every owner
has their own, keyed in `state.fogs` and recomputed by the same `updateFog(state, fog, owner)`
(`fog.js:124`). The whole filter predicate is one existing exported function:
```js
isVisibleAt(state.fogs[seat], e.x, e.y) // engine/fog.js:49 — two divides, one array index
```
Measured cost of filtering both entity collections for one seat: **0.24–0.69 ms** at 400–1,600 units
(§4.3). The server already pays `updateFog` per owner every tick regardless — filtering adds one
O(1) lookup per entity per seat, which is noise next to the `JSON.stringify` it *saves*.
The engine also documents the exact semantics we need: *"There's no 'remembered snapshot' of enemy
positions once they leave vision — they simply stop rendering"* (`fog.js:14-15`). So the shipped
game has **no last-known-position ghosts today**. Filtering therefore strips nothing the current
renderer draws for enemies.
### 4.2 What filtering would strip that the renderer legitimately needs
Six things. Five are already solved by how the codebase is built; one is a real decision.
1. **The static map — already free.** `serializeGame` does not include `state.map`; terrain, node
positions and base positions are regenerated from `seed` + `planetId` by `rehydratePlanet`
(`engine/persist.js`). The client is told the map config once at match start and builds the world
itself. **Nothing per-tick.**
2. **Resource nodes — must NOT be filtered by `visible`.** `renderNodes.js:26` and
`inputCommands.js:69` gate nodes on `isNodeDiscovered(state.fog, n)`, which reads **`explored`**,
not `visible` (`fog.js:66-67`) — deliberately: charted deposits are "map knowledge, not
battlefield intel" (`fog.js:10-13`), and only `hidden` caches need scouting. A filter that used
`visible` would make every deposit blink out the moment a worker walks away. **Filter nodes on
`explored`, not `visible`** — and note that `n.amount` changes as *anyone* mines: sending live
amounts for a node in enemy territory is an intel leak. Send `amount` only for nodes currently
`visible`; send last-seen `amount` otherwise.
3. **Craters and wrecks — survive filtering for free.** `engine/wreckage.js` and `engine/bomb.js`
turn into *resource nodes* with `crater: true` / `wreck: true`
(`engine/persist.js:549`, `:682-708`), drawn by `renderNodes.js:28` / `:114-150`. They are map
knowledge under rule 2, not entities. Pending (not-yet-matured) craters and wrecks are separate
top-level arrays (`persist.js:554`, `:562`) and are small — 0.0 KB and 0.8 KB measured.
**Send both unfiltered; they are terrain, and the deposit's existence is not secret intel.**
4. **The fog grid itself — send one, not two.** `serializeGame` ships `fog` **and** `fogAI`
(`persist.js:566-567`): **31 KB each on a 4× map**. Broadcasting both to every client literally
hands each player their opponent's vision map. The client needs exactly one grid — its own — and
only `explored` (which is what `drawFogBase`, `render.js:201-244`, paints from, plus
`isNodeDiscovered`). `visible` can be recomputed client-side from the entities the client can
see, or sent as a delta; `explored` is monotone and compresses to a run-length or a bitset
trivially (16,000 cells → 2 KB as a bitset, vs 31 KB as a JSON array of 0/1).
5. **Events — must be filtered, and the rule already exists.** `boot.js:675` reads
`if (ev.owner !== "player" && !isVisibleAt(state.fog, ev.x, ev.y)) continue;` — the client already
applies exactly the per-seat event filter the server needs. **Move that line to the server, per
seat, verbatim.** The under-attack trigger (`boot.js:684`, `:704`, `:712`) and the supply-block
beep (`:737`) then need the target-owner field from §3.2.
6. **Interpolation continuity — already safe, which is the surprise.** Under filtering an entity can
legitimately vanish between snapshots (it walked into fog) or appear mid-motion far from where it
was last seen. That is normally the first thing to break when a client stops holding the whole
world. Here all three cases are already handled, by code written for a different reason:
- **Appearing.** `lerpXY` (`renderShared.js:56-58`) returns the *live* entity when it has no
baseline — *"a unit spawned this tick"*. A newly-visible enemy snaps to its true position
instead of lerping from nowhere. ✔
- **Reappearing far away.** `TELEPORT_SQ = 60*60` (`renderShared.js:40`, applied at `:59-60`):
*"a one-tick move past this is a teleport, not motion — don't lerp it."* A unit re-sighted
across the map snaps rather than sliding. ✔
- **Vanishing.** `pruneFacing(state)` runs **every frame**, at the top of `drawFrame`
(`render.js:112`), dropping `prevPos` and `facing` entries for ids no longer in `state.units`
(`renderShared.js:69-76`). An entity that leaves the filtered view is cleaned up that frame;
if it comes back it has no baseline and takes the "appearing" path above. ✔
One cosmetic residue: a re-sighted enemy loses its remembered facing and re-derives from
`updateFacing`'s default (`renderShared.js:97`), so it may point "up" for a frame or two before
its next movement corrects it. A one-line fix if it ever reads badly (keep `facing` entries
longer than `prevPos`); not a blocker.
**`snapshotPositions` needs no change at all.** Called from `boot.js:501` immediately before each
`tick`, it becomes "immediately before applying each authoritative snapshot" — which is precisely
snapshot interpolation. The client already has the mechanism a networked renderer needs.
### 4.3 Measured payloads (this machine, `engine/persist.js` format)
`serializeGame` is the *save* format, not a wire format, so treat these as an upper bound with the
right shape. Node v22, same container as dossier 00.
| Scenario | Units | Full snapshot | `stringify` | Fog arrays | Fog-filtered per seat |
|---|---|---|---|---|---|
| Standard map, natural 20-min self-play match | 21 | 19.2 KB | 0.23 ms | 2 KB × 2 | 2.6 KB / 0.04 ms |
| Standard map, 200 v 200 | 406 | **96 KB** | 0.82 ms | 2 KB × 2 | 14 KB / 0.25 ms |
| 4× map, 200 v 200 | 406 | **154 KB** | 2.56 ms | **31 KB × 2** | 14 KB / 0.24 ms |
| 4× map, 400 v 400 | 806 | **236 KB** | 3.21 ms | 31 KB × 2 | 28 KB / 0.35 ms |
| 4× map, 800 v 800 | 1,606 | **404 KB** | 4.45 ms | 31 KB × 2 | 55 KB / 0.69 ms |
At 20 Hz with 4 seats:
- **Full broadcast**, 4× / 800v800: 404 KB × 20 × 4 = **32 MB/s egress**, and 4.45 ms × 4 =
**17.8 ms of a 50 ms tick** in `JSON.stringify` alone (the sim itself costs 22 ms p99 at this
size per dossier 00 — together they blow NFR-2).
- **Fog-filtered**, same scenario: 55 KB × 20 × 4 = 4.4 MB/s, 2.8 ms total. Still too much for
20 Hz snapshots without deltas, which is why §4.4's migration path exists — but it is the
difference between "needs delta encoding" and "architecturally impossible".
- **Realistic band** (4× map, 200v200, the near-supply-cap case dossier 00 calls normal): full
154 KB → 12 MB/s; filtered 14 KB → 1.1 MB/s.
Dossier 00's conclusion is confirmed from the other side. The sim runs ~500× real time; the server
spends its wall clock waiting. **Serialization is the cost centre, and it scales with what you send,
not with what you simulate.**
### 4.4 Recommendation
**Fog-filtered per client. From day one. Not as an optimization — as the architecture.**
Four reasons, in order of weight:
1. **ADR-0003 chose server authority specifically so fog could not be map-hacked** (its Context §1
and §2). Full-state broadcast reinstates the exact property lockstep was rejected for: every
client holds the whole map, and fog becomes a client-side rendering courtesy. It would make the
central architectural decision decorative.
2. **PRD G2 makes agents first-class players.** The MCP server (dossier 05) has to hand an agent a
legitimate, seat-scoped view. That filter has to exist regardless. Building it once and using it
for browsers too is strictly less work than building it for agents and a bypass for browsers.
3. **The numbers say full-broadcast does not fit** (§4.3), and the cost is in the *format*, not the
*filter*: `fog`+`fogAI` alone are 62 KB of the 154 KB at 4×, and both are per-seat secrets.
4. **Retrofitting is worse than starting there.** Every client module written against "I hold
everything" acquires a quiet dependency on data it should not have. `renderShared.js:253`'s
comment already names the failure mode: *"an inverted or dropped test doesn't crash or look wrong,
it quietly paints the enemy's army through the fog."*
**Trade-offs accepted, stated plainly:**
| Cost of filtering | Mitigation |
|---|---|
| Server does N filter passes per tick instead of 1 serialize | Measured 0.24–0.69 ms per seat; the sim it accompanies is 0.2–22 ms. Noise. |
| Interpolation must handle entities appearing/vanishing | **Already handled** — §4.2 item 6. Zero lines. |
| Spectator/replay wants full state | Give the **spectator** a synthetic all-seeing seat (`observerMode` already bypasses `hiddenByFog`, `renderShared.js:254`). One extra filter config, not an extra path. |
| Harder to debug ("why is the client missing X?") | A dev-only `--no-fog-filter` server flag, and a test that asserts a filtered snapshot contains no entity the seat cannot see. |
**Migration path** (each step independently shippable and testable):
1. **Send the map config, not the map.** Client rebuilds terrain/nodes from `{planetId, seed,
sizeMult, resourceMult}`. Removes the largest static payload before any filtering exists.
2. **Send one fog grid, not two**, and only `explored`, as a bitset. −60 KB/tick at 4× and closes
the vision leak immediately.
3. **Ship a full-snapshot loopback first** (ADR-0004). No filter, no socket: proves the
client-renders-a-given-state seam with the inherited tests still running.
4. **Insert the filter in the loopback.** `filterFor(state, seat)` → the same `View` shape. Single-
player now pays for and therefore *tests* the filter (ADR-0004's explicit intent). Assert
`filterFor(state,"player")` contains no entity outside `state.fogs.player`.
5. **Delta-encode.** Only entities whose fields changed since the client's last acked snapshot, plus
an id list for removals. This is where 55 KB becomes a few KB; do it after correctness.
6. **Then and only then, the socket.**
---
## 5. What is reusable verbatim
Client tree: **19,635 LOC** (17,903 JS + 1,732 CSS) across 38 files.
### 5.1 Zero change — 2,850 LOC (14.5%)
| Module | LOC | Why |
|---|---|---|
| `style.css` | 1,732 | No owner concept. Gains additive rules for the lobby and `netStatus`. |
| `data.js` | 242 | Pure display data (planet names, commodity table). One incidental `"ai"` — a **commodity** id (`:124`), not an owner. |
| `effects.js` | 183 | Particle/decal bookkeeping over `{x,y,type}`. Fed by replicated events instead of local ones — no signature change. |
| `sound.js` | 181 | Zero references to `state`, `player` or `ai`. |
| `renderNodes.js` | 171 | Owner-free; its one coupling is `state.fog` (`:26`), which the alias rebind (§3.4) handles. |
| `update.js` | 108 | Version-chip / auto-update. Untouched. |
| `camera.js` | 105 | Pure math. Zero references to `state`, `player`, `ai`. |
| `dom.js` | 68 | Element handles. |
| `version.js` | 60 | Version + save-impact display. |
### 5.2 Near-zero change — 5,942 LOC (30.3%)
A `localOwner` substitution, one predicate, or an added parameter. **No restructuring.**
| Module | LOC | Touch points |
|---|---|---|
| `renderBuildings.js` | 807 | 6 owner literals (`:84`, `:120`, `:147`, `:165`, `:181`, `:233`) |
| `renderUnits.js` | 699 | 1 (`:91`) |
| `renderEffects.js` | 683 | 5 (`:413`, `:419`, `:563`, `:600`, `:637`) |
| `overlays.js` | 585 | 10 objectives (`:68-81`) + 4 victory-copy sites (`:376`, `:392`, `:406`, `:407`) + 1 faction chip (`:34`) |
| `setup.js` | 462 | Option model and map cards verbatim; card click re-targets to the lobby. New lobby screen is *additive*. |
| `hud.js` | 452 | 6 owner literals + 2 `players.player.resources` reads + the 1v1 score chip (`:314`) |
| `hudPanelSignature.js` | 380 | 4 owner literals + 6 `players.player` reads. The signature *mechanism* — repaint only when the panel's meaningful inputs change — is exactly right for a snapshot-driven client and should be kept. |
| `techChart.js` | 366 | 1 line: `const OWNER = "player"` (`:36`) → `state.localOwner`. |
| `observer.js` | 304 | Repurposed as the spectator client (§7); 4 sites (`:116`, `:276`, `:290`, `:291`) |
| `render.js` | 277 | `state.fog` (`:202`) only — the interpolator needs nothing (§4.2 item 6) |
| `renderShared.js` | 255 | 1 predicate: `hiddenByFog` (`:253-255`) |
| `observerPanel.js` | 246 | 1 site (`:89`) |
| `main.js` | 168 | 2 `issue*` (`:149-150`) → transport sends. Everything else (canvas resize, DPR, panel folds, mute) verbatim. |
| `minimap.js` | 138 | 2 (`:94`, `:99`) |
| `session.js` | 120 | Additive fields only (§2.1) |
### 5.3 Real work — 3,662 LOC (18.6%)
| Module | LOC | Touch points | Nature |
|---|---|---|---|
| `hudSelection.js` | 1,998 | **67 distinct lines** (7 comparisons + 10 owner args + 11 property paths + 5 `issue*` + 33 mutators + 2 raw writes, one line double-counted) | Every panel's *layout* is verbatim; only the click handlers change from "call the engine" to "send a command", and the affordability reads re-point at the local seat. **3.4% of the file.** Mechanical but wide. |
| `boot.js` | 788 | The lifecycle itself | §2.3. The genuinely architectural file. |
| `input.js` | 588 | **18** (5 comparisons + 6 `issue*` + 6 selection writes + `map.bases.player` `:430`), plus `placeBuildingAt` (`:517-525`) going async | Gesture handling, camera, hotkeys, control groups all verbatim. **3% of the file.** |
| `inputCommands.js` | 288 | **35 distinct lines** (16 comparisons + 13 `issue*` + 5 selection writes + `state.fog` `:69`) — **12% of the file** | The densest coupling in the tree, and the most important to get right: it is the whole right-click dispatch. |
### 5.4 Out of multiplayer v1 — 6,779 LOC (34.5%)
`competition.js` 3,773 · `competitionLedger.js` 1,290 · `pairing.js` 518 · `competitionWorker.js` 355
· `starmap.js` 318 · `landingPicker.js` 211 · `playerFingerprint.js` 167 · `elo.js` 147. See §7.
### 5.5 Repurposed — 402 LOC (2.0%)
`saveload.js` 371 · `saveShape.js` 31. See §6.
### 5.6 The number that matters
Excluding the modules that do not ship in v1, the **in-scope client is 12,856 LOC**:
| Verdict | LOC | Share of in-scope |
|---|---|---|
| Verbatim | 2,850 | **22%** |
| Near-zero | 5,942 | **46%** |
| **Verbatim + near-zero** | **8,792** | **68%** |
| Real work | 3,662 | 28% |
| Repurposed | 402 | 3% |
**Roughly two-thirds of the shipping client is reusable as-is or with per-site substitutions.** That
is the strongest argument in this dossier for the port being a re-seaming job.
---
## 6. `saveload.js` / autosave / `localStorage` in multiplayer
### 6.1 What exists
`saveload.js` (371 LOC) runs two channels over `localStorage`:
- **Autosave** — a 12 s timer (`AUTOSAVE_INTERVAL_MS`, `:39`) plus `beforeunload` (`:370`),
writing `serializeGameString(game.state)` to `stellarfrontier.save.v1` (`:31`) or
`serializeGalaxyString(game.galaxy)` to `stellarfrontier.odyssey.v1` (`:32`), with a
rotated previous generation (`KEY + '.prev'`, `:33`, `writeGeneration` `:84-95`).
- **File export/import** — `saveToFile` (`:166`), `loadFromFile` (`:185`).
- **Resume** — `loadGame` (`:138`) / `loadOdyssey` (`:127`) feed `bootState`/`bootGalaxy`; the
setup screen's "Continue" button (`setup.js:347`, `:372`).
- **Failure surfacing** — `recordAutoSaveOutcome` (`:338`) toasts after 3 consecutive failures.
- `saveShape.js:29` `resumableMode` already refuses to checkpoint a state that is `over`, a
scenario, or a spectated match.
Other `localStorage` users: `update.js:23-24` (dismissed-update flag), `overlays.js:97-100`
(help-strip seen flag), `competitionLedger.js:1238-1290` (the ratings ledger).
### 6.2 Recommendation
**Client-side autosave of match state is deleted in multiplayer. Not disabled — deleted from the
multiplayer path.**
Reasoning:
1. **The client no longer holds the authoritative state.** `serializeGameString(game.state)` on a
fog-filtered view produces a save of a *partial world*. Loading it would resume a game with the
enemy's army missing. There is no correct thing for it to write.
2. **A single seat cannot resume a 4-seat match.** Resumption is a *match-level* operation: it needs
every seat's consent and every seat's presence. That makes it a server feature or nothing.
3. **"Continue" would be a lie.** The setup screen's Continue button restores *your* game. In
multiplayer there is no such thing.
**What replaces it:**
| Concern | Multiplayer answer |
|---|---|
| Crash / refresh mid-match | **Server-side match persistence + rejoin by `matchId`.** The server holds the state anyway; snapshot it to disk on a slow cadence (30–60 s) as a host-crash guard. The client keeps `{matchId, seat, token}` in `localStorage` — a few dozen bytes — and offers "Rejoin match" on the splash. This is the *right* use of `localStorage` here. |
| Replay / post-match analysis | **Server-side command log**, not a client save. Dossier 02 D7: a match replays from `{engineCommit, createGameStateOpts, dt, aiSeatConfigs, orderedCommandLog}`. That is a few KB for a whole match versus 400 KB for one snapshot, and it is the artefact the MCP/agent work (dossier 05) wants anyway. |
| Single-player | **Autosave stays, unchanged.** Under ADR-0004 the loopback server is in the tab and holds the full authoritative state. `saveload.js` re-points from `game.state` to `session.server.state` — a one-line change to `snapshot()` (`:68-76`) — and every existing test keeps meaning what it meant. |
**Keep, verbatim:** `update.js`'s dismissed-update flag, `overlays.js`'s help-strip flag,
`game.formation` / `game.collapsedSections` / `game.groups` if they are ever persisted. These are
per-viewer preferences, exactly what browser storage is for.
**Guard rails to add:**
- `saveShape.js:29` `resumableMode` gains one clause: return `null` when `game.match` is a networked
match. That single line disables the timer, the `beforeunload` write and the Continue button
together, because everything funnels through `snapshot()`.
- A test asserting that no networked match ever writes `stellarfrontier.save.v1`. The failure mode
here is silent (`saveload.js:42` swallows storage exceptions by design) so it must be asserted, not
observed.
---
## 7. Odyssey / galaxy / competition / observer — in or out of multiplayer v1
### Odyssey (open-world campaign) — **OUT**
**Client LOC:** `starmap.js` 318 + `landingPicker.js` 211 = **529**, plus large fractions of
`hudSelection.js` (lanes, colony policy, freight, spaceport, capital, electrify — 22 of the 38
mutator sites in §1.2), the galaxy branch of `boot.js`'s loop (`:502-524`), `startOdyssey`/
`bootGalaxy`/`performJump`/`initiateJump`/`surrenderOdyssey`/`focusActivePlanet`/`notifyColony`/
`celebrateMilestone` (`boot.js:259-388`, `:605-652`) ≈ **180 LOC of `boot.js`**, and
`observer.js`'s galaxy spectating. Call it **~900 client LOC plus a third of `hudSelection.js`'s
behaviour**.
**Why out:**
1. **It is a different game shape.** Odyssey simulates *every world in the galaxy* each tick
(`boot.js:503` `stepGalaxy`), with the player controlling one and the rest running as background
colonies. Multiplayer would have to decide whose worlds tick, who sees which world, and what a
"jump" means when four players are on four different planets. That is a design project, not a port.
2. **It carries the engine's worst owner-literal concentration.** Dossier 01 finding #4:
35 of 53 engine owner literals live in `galaxy.js` (19), `scenarios.js` (4), `colonyPolicy.js` (3),
`sim.js`'s Odyssey-logistics block (4) and `diplomacy.js` (2) — *"in the Odyssey open-world and
scripted-mission layers, which a multiplayer skirmish never loads."* **Scoping Odyssey out of v1
removes two-thirds of the engine's N-player work.** This is the single highest-leverage scope
decision available.
3. **Credits are a galaxy-level singleton.** `game.galaxy.credits` (`hudSelection.js:291`, `:414`,
`:628`, `hud.js:257`, `starmap.js:87`) is one number for one player. There is no defined meaning
for it with 5 seats.
**Preserved through the loopback:** single-player Odyssey keeps working unchanged (ADR-0004), which
is the whole reason this scoping is cheap rather than a feature deletion.
**Keep the imports.** `main.js:26` side-effect-imports `starmap.js` for the M key and the galaxy
button. In a multiplayer build the module must still load (stubbed) or the hotkey silently disappears.
### Competitions / Elo — **OUT as shipped, but read `pairing.js` and `elo.js` first**
**Client LOC:** `competition.js` 3,773 + `competitionLedger.js` 1,290 + `pairing.js` 518 +
`competitionWorker.js` 355 + `playerFingerprint.js` 167 + `elo.js` 147 = **6,250 LOC — 32% of the
entire client tree, and the largest single module in it.**
**Why out:** the whole subsystem is a *local* ladder. `competitionLedger.js` stores ratings in
`localStorage` (`:1238-1290`); `competitionWorker.js` runs AI-vs-AI duels in a Web Worker;
`competition.js`'s Gauntlet plays fixtures against simulated opponents. Its founding constraint is
stated in `boot.js:148-153`: *"a human cannot play forty games"*, and *"NO side-swap, because the
human can only ever hold owner `player`"*. A real multiplayer ladder is server-side, cross-player and
authoritative — a different system that happens to share a rating formula.
**But two pieces are directly reusable and should not be rewritten:**
- **`elo.js` (147 LOC)** — a pure rating function. Move it server-side as-is for a real ladder.
- **`pairing.js` (518 LOC)** — round-robin/Swiss pairing and schedule generation. This is
matchmaking. Read it before writing a lobby queue.
- `playerFingerprint.js` (167 LOC) — `fingerprintPlayer(state, owner = "player")` (`:53`) is already
owner-parameterized. Useful later for agent behaviour analysis (dossier 05), not for v1.
### Observer Mode — **IN, repurposed as the spectator client**
**Client LOC:** `observer.js` 304 + `observerPanel.js` 246 = **550**.
**Why in — it is the cheapest feature in the port.** Observer Mode already is a
render-a-state-you-do-not-control client:
- It bypasses fog at the single predicate (`renderShared.js:254`'s `observerMode` argument,
threaded from `boot.js:547-548`), rather than mutating `state.fog` (`observer.js` header,
`renderShared.js:250-251`).
- It has its own camera (`game.observerCamera`, `session.js:103`) independent of `game.input`'s.
- It makes `input.js` refuse to issue orders — *"every mouse/wheel/key path already early-returns
into observer.js while `game.observerMode` is on"* (`boot.js:197-199`).
- It renders a **different state object** than the one being played (`observedState()`,
`boot.js:545`) — which is precisely "render a replicated view".
**The changes are small:** `observer.js:290-291`'s `supplyUsed(state,"ai")` becomes per-seat;
`observerPanel.js:89`'s two-entrant naming becomes an N-seat list; entry gating moves from
"Odyssey or `spectateMatch`" (`observer.js`) to "the server assigned me a spectator seat".
**A spectator is exactly the synthetic all-seeing seat §4.4 needs.** Server-side, a spectator gets a
filter config of "everything" (or, for competitive integrity, a delayed full view). The client-side
machinery for it already ships.
### Scenarios (Escort / Raider / Bounty) — **OUT of multiplayer v1, IN via loopback**
`boot.js:234-254` — three single-player scripted missions with their own objectives. Co-op scenarios
are an appealing v2 (`setupEscort` etc. already build a full state), but they are PvE content with
2-side scripting. Single-player keeps them unchanged through the loopback.
---
## 8. Offline single-player preservation
### 8.1 The loopback works for this client, and the client is unusually ready for it
ADR-0004 Option C requires that the client never touches `engine/` mutation directly and instead
talks to a session through a transport. From the client's side that is:
- **64 mutation call sites** (§1.1, §1.2) become `session.send(cmd)`. Every one already has an
explicit argument list; dossier 02's codec owns the id→object resolution.
- **13 sim-ownership sites** (§1.5) move behind `session.createMatch(cfg)` / the transport's
match-start message.
- **`game.state` becomes the received view.** `session.js`'s read-at-call-time discipline
(`:8-10`) means every consumer already tolerates that object being swapped.
Because the loopback server runs **in the same tab**, single-player keeps: no network, no latency, no
service dependency, byte-identical determinism, and the full `serializeGame` save (§6).
### 8.2 Why this keeps the 2,519 tests meaningful
The inherited suite constructs a `State` and calls engine functions directly. Under ADR-0004:
- **`engine/` tests are untouched.** The engine's API does not change (dossier 02 D2: exactly one
signature change, `issueSetRally`).
- **Client tests that drive `boot.js`** (`test/boot.test.js` drives the real loop) keep working
because `bootState` keeps its shape (§2.3) and the loopback is synchronous.
- **The multiplayer machinery gets covered by every single-player test run** — the codec, the
ownership check and the per-seat filter all execute on the loopback path. That is ADR-0004's
entire thesis and it holds on the client side.
### 8.3 What in the client resists the loopback — five flags
1. **`input.js:517-525` `placeBuildingAt` needs a synchronous answer.** `issueBuild` returns the new
building's id and build mode exits only if it is truthy. Loopback is synchronous so this
*survives unchanged locally* — which is the trap: **it will pass every single-player test and
fail over a socket.** Either make the transport interface async-by-contract from day one (the
loopback resolving immediately), or the WebSocket path will be the first place anyone discovers
this. **Recommend: async-by-contract, and use ADR-0004's fault-injection mode (its Decision
section) to run the single-player suite with simulated latency in CI.** The same applies to
`hud.js:37`, `hudSelection.js:449/459/485/494` and `boot.js:287`.
2. **`boot.js:551` polls `game.state.over`.** Harmless over loopback, wrong over a socket (§2.3).
Make it a message from day one so the loopback exercises the message path.
3. **Refcounted pause gates `update()`** (`boot.js:500`). Correct for single-player, illegal for
multiplayer. The branch has to exist; ensure single-player takes the *same* code path with a
server-side pause command rather than a client-side `return`.
4. **`state.fog` alias rebinding is unsafe until the engine is fixed** — `engine/gather.js:64`,
`engine/scout.js:42` (§3.4, dossier 01 finding #6). Because the loopback server runs the same
engine in the same tab, a client-side rebind here does not merely mis-render, it **desyncs the
single-player sim**. Fix order matters.
5. **`main.js`'s side-effect imports** (`:26-28`) self-wire at module-load. A build that trims
modules for the multiplayer bundle must keep the import or explicitly re-wire; the failure is
silent (README already documents this).
### 8.4 What single-player retains, unchanged
Full-state rendering (its loopback server can hand it an unfiltered view, or the filtered one — it
should hand it the **filtered** one, so single-player tests the filter), autosave and Continue,
Odyssey, the three scenarios, competitions and the local ladder, Observer Mode, the file
export/import, and every hotkey. **Nothing in §7's "out of v1" list is deleted; it is only absent
from networked matches.**
---
## 9. Summary table
Verdicts: **verbatim** (no edits) · **light** (per-site substitution, no restructuring) ·
**heavy** (restructuring) · **server-side** (moves out of the browser) ·
**out-v1** (not in multiplayer v1; still shipped for single-player).
| Module | LOC | Verdict | Why |
|---|---|---|---|
| `style.css` | 1,732 | **verbatim** | additive lobby/net-status rules only |
| `camera.js` | 105 | **verbatim** | pure math, zero state coupling |
| `dom.js` | 68 | **verbatim** | element handles |
| `sound.js` | 181 | **verbatim** | zero owner/state references |
| `effects.js` | 183 | **verbatim** | `{x,y,type}` bookkeeping; fed by replicated events |
| `data.js` | 242 | **verbatim** | pure display data (`:124`'s `"ai"` is a commodity) |
| `version.js` | 60 | **verbatim** | version/save-impact chip |
| `update.js` | 108 | **verbatim** | auto-update chip; keeps its `localStorage` flag |
| `renderNodes.js` | 171 | **verbatim** | owner-free; `state.fog` (`:26`) via the alias rebind |
| `renderBuildings.js` | 807 | **light** | 6 literals: `:84`, `:120`, `:147`, `:165`, `:181`, `:233` |
| `renderUnits.js` | 699 | **light** | 1 literal: `:91`. Colour already generic (`:66`) |
| `renderEffects.js` | 683 | **light** | 5 literals: `:413`, `:419`, `:563`, `:600`, `:637` |
| `overlays.js` | 585 | **light** | objectives `:68-81`; victory copy `:376`, `:392`, `:406-407`; faction chip `:34` |
| `setup.js` | 462 | **light** + additive | option model verbatim; new `renderLobby()` (~150–250 LOC) |
| `hud.js` | 452 | **light** | 6 literals + 2 `players.player` reads + 1v1 score chip `:314` |
| `hudPanelSignature.js` | 380 | **light** | 4 literals + 6 `players.player` reads; the signature idea is *right* for snapshots |
| `techChart.js` | 366 | **light** | one line: `:36` `const OWNER = "player"` |
| `observer.js` | 304 | **light** | repurposed as spectator; `:116`, `:276`, `:290`, `:291` |
| `render.js` | 277 | **light** | `state.fog` `:202`; interpolator already fog-safe (§4.2.6) |
| `renderShared.js` | 255 | **light** | one predicate: `hiddenByFog` `:253-255` |
| `observerPanel.js` | 246 | **light** | `:89` two-entrant naming → N seats |
| `main.js` | 168 | **light** | 2 `issue*` `:149-150`; side-effect imports `:26-28` must survive |
| `minimap.js` | 138 | **light** | 2 literals `:94`, `:99`; colours already generic `:95`, `:100` |
| `session.js` | 120 | **light** | additive fields: `transport`, `match`, `localOwner`, `serverTick`, `lobby`, `netStatus` |
| `hudSelection.js` | 1,998 | **heavy** (wide, shallow) | 59 touch points = 3% of the file; layout verbatim, handlers become sends; **`:1045`, `:1722` need new engine commands** |
| `boot.js` | 788 | **heavy** | the lifecycle: loop split, match-end message, pause semantics, lobby entry |
| `input.js` | 588 | **heavy** | 17 touch points + `placeBuildingAt` `:517-525` goes async |
| `inputCommands.js` | 288 | **heavy** | 37 touch points = 13% of the file; the right-click dispatch |
| `saveload.js` | 371 | **server-side** | match persistence + rejoin move to the server; single-player keeps it (§6) |
| `saveShape.js` | 31 | **light** | one clause: refuse to checkpoint a networked match |
| `competition.js` | 3,773 | **out-v1** | local ladder vs simulated opponents |
| `competitionLedger.js` | 1,290 | **out-v1** | `localStorage` ratings store |
| `pairing.js` | 518 | **out-v1** | **read first** — this is matchmaking |
| `competitionWorker.js` | 355 | **out-v1** | Web Worker duel runner |
| `starmap.js` | 318 | **out-v1** | Odyssey galaxy map; keep the `main.js` import |
| `landingPicker.js` | 211 | **out-v1** | Odyssey jump landing site |
| `playerFingerprint.js` | 167 | **out-v1** | already owner-generic (`:53`); useful for agent analysis later |
| `elo.js` | 147 | **out-v1** | **reuse server-side** — pure rating function |
| **Total** | **19,635** | | |
**Roll-up (in-scope for v1 = 12,856 LOC):** verbatim 2,850 (22%) · light 5,942 (46%) ·
heavy 3,662 (28%) · server-side/repurposed 402 (3%). **Verbatim + light = 68%.**
---
## 10. Ordered recommendations
1. **Close the two raw entity writes before the codec ships.** `hudSelection.js:1045` routes to the
existing `issueSetHomeBase([unit], null)` (`engine/commands.js:281`) — no engine change at all.
`hudSelection.js:1722` needs a new `issueSetElectrified(state, buildingIds, on)`, with
`engine/aiIndustry.js:172` routed through it too so the field has exactly one writer. Smallest
change in this document, largest correctness payoff: these are the only client mutations dossier
02's codec cannot see.
2. **Fix `engine/gather.js:64` and `engine/scout.js:42` before anything rebinds `state.fog`.**
Dossier 01 finding #6. Under the loopback this is a desync, not a render bug.
3. **Introduce `state.localOwner` (or `game.localOwner`) and sweep the 71 "is this mine" literals
site-by-site.** Never with a blind `sed`: `data.js:124` defines a commodity called `"ai"`.
4. **Scope Odyssey and competitions out of multiplayer v1.** Removes 6,779 client LOC and — per
dossier 01 finding #4 — two-thirds of the engine's owner-literal work. Both keep working in
single-player via the loopback.
5. **Make the transport interface asynchronous by contract from day one**, and run the single-player
suite under ADR-0004's fault-injection latency in CI. Otherwise `input.js:521` and the four other
return-value sites pass every test and break over the socket.
6. **Replace the `game.state.over` poll (`boot.js:551`) with a `match-end` message immediately**, so
the loopback exercises the message path from the first commit.
7. **Ship fog-filtered replication from day one** (§4.4), following the six-step migration path.
Send the map config not the map, and one `explored` bitset not two JSON fog arrays.
8. **Keep `bootState` as the single boot funnel and `session.js`'s read-at-call-time discipline.**
They are why the rest of this is cheap.