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 owns everything inside engine/; 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 returns 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:

// 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 Uint8Arrays 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:

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.