SpaceCities / docs /analysis /03-client-coupling.md
Almaatla's picture
docs: delivery plan, engine/command dossiers, and the ADRs they ground
ef7537c unverified
|
Raw History Blame
59.1 kB
# 03 β€” Client coupling: what must change to become a networked client
**Status:** analysis, input to ADRs and `TASKS.md`. Not a specification.
**Source audited:** `/home/user/alma92350/spaceexploration-rts` @ working tree (read-only clone).
**Assumed decision (upstream):** server-authoritative headless Node sim; the browser renders a
state it is given and sends commands instead of mutating locally.
All `file.js:line` citations are against the SOURCE tree. Line numbers are exact at the time of
writing; they are cited so a reader can confirm each claim, not as a patch plan.
---
## 0. Headline findings
Five facts shape everything below. They are the reason this port is a re-seaming job and not a
rewrite.
1. **The client barely mutates the sim.** Outside `engine/`, sim mutation happens at exactly
**56 call sites** across **6 files** β€” and *all but 10 of them* go through named engine
functions (`issue*`, `queueProduction`, `sell`/`buy`, …) that already take an explicit
`(state, entityId, args)` shape. Those 46 sites are already command envelopes; they just
aren't serialized. The other 10 are `state.selection = …`, which isn't sim state at all
(`engine/persist.js:777` resets it to `[]` on load).
2. **No client file writes `resources` or `credits` directly.** Verified by grep β€” zero hits.
Every economic mutation is an engine call. The trust boundary is therefore already drawn in
the right place; it is simply not enforced.
3. **The engine is already owner-generic in its bones.** `state.owners` is the canonical side
list, `state.players` and `state.fogs` are keyed by owner id, and `state.fog`/`state.fogAI`
are documented *aliases* into `state.fogs` (`engine/state.js:167-189, 227-234`), pinned by
`test/ownerScaffold.test.js`. Victory already iterates `state.owners`
(`engine/victory.js:11, 30, 165`). "Seat 3 of 5" is a change to `ownerDefs`
(`engine/state.js:174-181`), not a sweep.
4. **The renderers are already owner-generic for colour and already fog-gate every entity.**
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`); the
fog rule is factored into one named predicate, `renderShared.js:253 hiddenByFog`. Nothing
hardcodes `#4fd1ff`/`#f87171` for entities β€” those literals live in `engine/state.js:179-180`
as *data*.
5. **The sim is cheap.** Measured on this machine: a full skirmish with **both** seats
AI-driven costs **0.096–0.142 ms/tick** (27,000 ticks, 15 sim-minutes, small and 2Γ— maps).
At 20 Hz that is ~0.2–0.3 % of one core per match. A single Node process can host dozens of
concurrent matches without breaking a sweat. Headless authority is not a performance risk.
The real work is concentrated in three places: `boot.js` (the lifecycle), `input.js` +
`inputCommands.js` + `hudSelection.js` (the ~46 synchronous engine calls), and the ~110
hardcoded `"player"`/`"ai"` owner literals scattered across the HUD and renderers.
---
## 1. The local-authority inventory
### 1.1 `engine/commands.js` `issue*` β€” direct order issuance
`engine/commands.js` exports 22 `issue*` functions (lines 231–531). Four client files import
them. **Every one of these becomes `submitCommand(envelope)`.**
| Site | Call | Notes |
|---|---|---|
| `main.js:21` | *import* `issueMove, issueAttackMove` | minimap right-click |
| `main.js:149` | `issueAttackMove(combatants, world.x, world.y)` | minimap command |
| `main.js:150` | `issueMove(others, world.x, world.y)` | minimap command |
| `input.js:12` | *import* `issueBuild, issueStop, issueScout, issueHold, issueHoldFormation, issuePatrol` | |
| `input.js:346` | `issueStop(selectedUnits())` | S key |
| `input.js:351` | `issueScout(selectedUnits())` | scout toggle |
| `input.js:355` | `issueHold(selectedUnits())` | H key |
| `input.js:372` | `issuePatrol([u], points)` | per-unit patrol loop |
| `input.js:379` | `issueHoldFormation(selectedUnits(), game.formation.shape, game.formation.leaderPos)` | reads client-only `game.formation` (`session.js:75`) |
| `input.js:521` | `issueBuild(state, worker.id, buildingType, p.x, p.y)` | **returns a value the client uses** β€” see Β§1.6 |
| `inputCommands.js:27` | *import* 11 `issue*` | the right-click router |
| `inputCommands.js:94` | `issueAttackMove(combatants, x, y, queue, formation)` | |
| `inputCommands.js:95` | `issueMove(others, x, y, queue, formation)` | |
| `inputCommands.js:199` | `issueSetRally(building, p.x, p.y, node?.id)` | passes a **live building object**, not an id |
| `inputCommands.js:212` | `issueAssistBuild(workers, target.id, target.type, queue)` | |
| `inputCommands.js:226` | `issueServiceBuilding(workers, target.id, queue)` | |
| `inputCommands.js:235` | `issueRepair(workers, target.id, queue)` | |
| `inputCommands.js:245` | `issueSetHomeBase(eligible, target.id)` | |
| `inputCommands.js:249` | `issueAttack(attackers, target.id, queue)` | |
| `inputCommands.js:259` | `issueFerryFreighter(workers, target.id, queue)` | |
| `inputCommands.js:266` | `issueRepair(workers, target.id, queue)` | |
| `inputCommands.js:273` | `issueEscort(escorts, target.id, queue)` | |
| `inputCommands.js:278` | `issueGather(workers, node.id, queue)` | |
| `inputCommands.js:282` | `issueMove(selected, p.x, p.y, false, currentFormation(heading))` | |
| `hudSelection.js:21` | *import* `issueSetAILogistics, issueSetCollectPoint, issueSetLogiPriority, issueRecycle, issueCancelRecycle` | |
| `hudSelection.js:518` | `issueSetAILogistics([f], !on, state)` | |
| `hudSelection.js:542` | `issueSetCollectPoint([f], !on)` | |
| `hudSelection.js:600` | `issueSetLogiPriority(state, b.id, LOGI_PRIORITY_NEXT[cur])` | |
| `hudSelection.js:1821` | `issueCancelRecycle(recyclingNow)` | passes **live entity objects** |
| `hudSelection.js:1835` | `issueRecycle(recyclable)` | passes **live entity objects** |
| `tools/ailab.js:78,82,168-320` | `issueAttackMove`, `issueBuild` | headless bench, not shipped client |
**Shape problem to fix once:** most `issue*` take **arrays of live unit objects**, and three take
live building/entity objects (`inputCommands.js:199`, `hudSelection.js:1821`, `:1835`). A wire
envelope must carry **ids**. Recommended: add one dispatcher,
`applyCommand(state, seat, {verb, ids, args})`, in `engine/commands.js` that resolves ids β†’
entities, **filters to entities owned by `seat`** (this is the anti-cheat gate, and it does not
exist anywhere today), then calls the existing `issue*` unchanged. Every existing
`test/commands.test.js` assertion (503 LOC) keeps passing against the raw `issue*`.
### 1.2 Non-`issue*` engine mutators called from the client
These are just as authoritative and are easy to miss because they don't share a prefix.
| Site | Call | Subsystem |
|---|---|---|
| `hudSelection.js:1098, 1104, 1198, 1629` | `queueProduction(state, b.id, t[, true])` | production |
| `hudSelection.js:197` | `cancelProduction(state, building.id, i)` | production |
| `hudSelection.js:1149` | `researchUpgrade(state, refinery.id, u.id)` | tech |
| `hudSelection.js:1179` | `researchTech(state, datacenter.id, t.id)` | tech (Odyssey) |
| `hudSelection.js:235` | `cancelResearch(state, building.id, i)` | tech |
| `hudSelection.js:381, 395, 405` | `sell(game.galaxy, state, com, qty)` | market (Odyssey) |
| `hudSelection.js:414` | `buy(game.galaxy, state, com, TRADE_LOT)` | market (Odyssey) |
| `hudSelection.js:449, 485` | `loadFreighter(state, f.id, com, qty)` | freight (Odyssey) |
| `hudSelection.js:459, 494` | `unloadFreighter(state, f.id, com, qty)` | freight (Odyssey) |
| `hudSelection.js:630` | `offerTribute(game.galaxy, state)` | diplomacy (Odyssey) |
| `hudSelection.js:661` | `fulfillRequest(game.galaxy, state)` | diplomacy (Odyssey) |
| `hudSelection.js:705` | `offerGift(state, com, TRADE_LOT)` | diplomacy (Odyssey) |
| `hudSelection.js:766, 777, 781, 793` | `unassignShipFromLane / assignShipToLane / deleteLane / createLane` | lanes (Odyssey) |
| `hudSelection.js:831, 837, 850, 870, 876` | `setColonyPolicy(g, planetId, …)` | colony policy (Odyssey) |
| `hudSelection.js:900` | `upgradeToCapital(state, cc)` | Odyssey |
| `hudSelection.js:905` | `packCommandCenter(state, cc.id)` | Odyssey |
| `hudSelection.js:1386` | `upgradeSpaceport(state, spaceport)` | Odyssey |
| `hudSelection.js:1455` | `lightFuse(state, bomb)` | bombs β€” **skirmish, must be a command** |
| `hudSelection.js:1750` | `deployColonyShip(state, colonyShip.id)` | Odyssey |
| `hud.js:37` | `repairConvoy(game.state)` | scenario |
| `hud.js:38` | `departNow(game.state)` | scenario |
| `starmap.js:229, 239, 244` | `setColonyPolicy(g, w.id, …)` | Odyssey |
For **v1 skirmish MP**, the mandatory set is small: `queueProduction`, `cancelProduction`,
`researchUpgrade`, `cancelResearch`, `lightFuse`, plus the `issue*` family. Everything marked
Odyssey is out of scope (Β§7) and stays reachable only on the offline path (Β§8).
### 1.3 Direct `state.*` writes
Only two kinds exist in the whole non-engine tree.
| Site | Write | Verdict |
|---|---|---|
| `input.js:318, 337, 382, 395, 411, 543` | `state.selection = …` | **Not sim state.** Move to the client session. |
| `inputCommands.js:147, 152, 154, 157, 177` | `state.selection = …` | same |
| `boot.js:766` | `state.events.length = 0` | The client *drains* the sim's event queue. On the server path, events arrive per-snapshot and are consumed the same way. |
`state.selection` also has **2 engine references** (`engine/state.js:346` prunes dead ids;
`engine/galaxy.js:1422` clears both sides on a jump), **32 client references** and **124 test
references**. It is single-seat by construction and *must* move out of the shared sim state β€” it
is the single largest mechanical edit in the port. `engine/persist.js:777` already proves it is
disposable (it saves as `[]`).
### 1.4 `state.players.player.resources` and friends β€” direct owner-keyed reads
There are **no writes**. Reads (all of which become "read *my* seat"):
| Site | Read |
|---|---|
| `hud.js:99` | `{ ...state.players.player.resources }` (flow history sample) |
| `hud.js:174` | `state.players.player.resources` (topbar readout) |
| `hudSelection.js:272, 363, 432, 522, 680` | `state.players.player.resources` |
| `hudSelection.js:651` | `state.players.player.resources[req.com]` |
| `hudSelection.js:508, 1122, 1157` | `state.players.player.upgrades` |
| `hudSelection.js:1941` | `state.players.player.color` (button sprite tint) |
| `hudSelection.js:1955` | `canAfford(state.players.player.resources, cost)` |
| `hudPanelSignature.js:146, 264, 344` | `state.players.player.resources` |
| `hudPanelSignature.js:180` | `state.players.player.upgrades` |
| `hudPanelSignature.js:227, 228` | `state.players.player.resources[…]` |
| `overlays.js:34` | `st.players.player.faction` **and `st.players.ai.faction`** |
| `observer.js:276` | `state.players.ai` (spectator stats) |
| `techChart.js:36` | `const OWNER = "player"` β€” the whole module's owner, used at `:138, :153, :196` |
**Fix:** one accessor. `me(state)` β†’ `state.players[session.seat]`. 20 sites, purely mechanical.
`overlays.js:34` and `observer.js:276` are the two that read *someone else's* player record and
therefore need a public-facts channel under filtering (Β§4).
### 1.5 `createGameState` / `tick()` / `createLoop` β€” the sim ownership itself
All in `boot.js`. This is the file that becomes the transport.
| Site | Call |
|---|---|
| `boot.js:14` | *import* `createGameState` |
| `boot.js:16` | *import* `createLoop` |
| `boot.js:17` | *import* `tick` |
| `boot.js:22` | *import* `createSelfPlayState, tickSelfPlay, SELFPLAY_HZ` |
| `boot.js:119` | `createGameState({ planetId, seed, rng: mulberry32(seed), … })` β€” skirmish |
| `boot.js:169` | `createGameState({ … })` β€” competition fixture |
| `boot.js:207` | `createSelfPlayState({ … })` β€” watched AI-vs-AI |
| `boot.js:236, 244, 252` | `setupEscort / setupRaider / setupBounty` β€” scenarios |
| `boot.js:262` | `createGalaxy({ … })` β€” Odyssey |
| `boot.js:287` | `jumpCapital(game.galaxy, destId, …)` |
| `boot.js:344` | `surrenderGalaxy(game.galaxy)` |
| `boot.js:485` | `loop = createLoop({ hz, speed, update, render })` |
| `boot.js:503` | `stepGalaxy(game.galaxy, dt)` |
| `boot.js:504` | `sweepColonies(game.galaxy, dt)` |
| `boot.js:530` | `tickSelfPlay(game.state, dt)` |
| `boot.js:531` | `tick(game.state, dt)` β€” **the single skirmish tick** |
Note `engine/loop.js:34` `createLoop` is a *render* loop with an embedded fixed-step accumulator.
On the server, the accumulator half is reusable verbatim; the `requestAnimationFrame` half
(`engine/loop.js:69, 77, 81`, each marked `browser-exempt`) needs a `setInterval`/timer driver.
Recommendation: extract the accumulator into a `stepper` and give it two drivers, rather than
forking the file β€” the fixed-step semantics documented at `engine/loop.js:18-26` are what make
replay determinism hold, and they must not diverge.
### 1.6 Return values the client consumes β€” the "optimistic" hazards
Three call sites use the *return value* of a mutation, which a fire-and-forget command envelope
cannot provide:
- `input.js:521` β€” `issueBuild(...)` returns the new building id or `null`; the client uses the
falsy result to keep build mode armed and play a rejection sound. Under server authority this
becomes a *rejection message* or an optimistic ghost that clears on the next snapshot.
- `hudSelection.js:449, 459, 485, 494` β€” `loadFreighter`/`unloadFreighter` return the quantity
actually moved and drive the UI feedback (`sound.playProductionBlocked()` on 0). Odyssey-only.
- `boot.js:287` β€” `jumpCapital` returns a result object or `null`. Odyssey-only.
Only the first matters for v1.
---
## 2. `session.js` / `boot.js` β€” the lifecycle, and how it re-shapes
### 2.1 `session.js` today (120 LOC)
A single exported mutable object, `game`, that every UI module reads **at call time** (never
destructured at module scope) so a restart is picked up automatically. Its 17 fields are exactly
three kinds of thing:
| Kind | Fields | MP fate |
|---|---|---|
| **The sim handles** | `state` (`session.js:16`), `galaxy` (`:22`) | Become *received* state; `galaxy` out of v1 |
| **The input controller** | `input` (`:17`) | Stays; becomes command-emitting rather than state-mutating |
| **Per-viewer UI state** | `supplyBlockedUntil` (`:34`), `lastAttackAt` (`:40`), `lastGateCharge` (`:52`), `colonyAlerts` (`:64`), `groups` (`:69`), `formation` (`:75`), `collapsedSections` (`:84`), `observerMode` (`:89`), `spectateId` (`:92`), `spectatePreview` (`:99`), `observerCamera` (`:103`), `spectateSpeed` (`:119`) | **Stays exactly as-is.** None of it is sim state; all of it is per-client. |
| **Match-provenance flags** | `competition` (`:30`), `spectateMatch` (`:114`) | Replaced by `match` (server-issued match descriptor) |
**This object is the right shape already.** The port adds fields, it does not restructure:
`seat` (my owner id), `seats` (public roster: id, name, colour, faction, isAI/isAgent),
`transport`, `connection` (connecting/live/desynced/dropped), `serverTick`, `selection`
(moved off `state`), and `role` (`"player" | "spectator"`).
### 2.2 `boot.js` today β€” the lifecycle in one line each
```
main.js:165 renderMapSelect() splash / map-select screen (setup.js:306)
β”” user picks world + dials β†’ setup (setup.js:104, the shared config object)
boot.js:109 startGame(planetId) resolveSeed β†’ difficultyDials β†’ createGameState
boot.js:443 bootState(newState, {intro}) THE single boot path β€” 5 other entry points funnel here
:444-446 stop old loop, destroy old input, exitObserverMode
:447-451 hide mapSelect / gameOver / underAttack overlays
:453-457 clear galaxy / competition / spectateMatch / groups / colonyAlerts
:458 game.state = newState
:463-465 seed chip, faction chip, objectives strip
:466 game.input = attachInput(canvas, state, () => renderHUD())
:471-475 open camera on state.map.bases.player, clamp
:476-482 resetEffects / resetFacing / resetPanelSignature / resetWorldUiBookkeeping / clearPause
:485 loop = createLoop({ hz: PLAY_HZ(20) | SELFPLAY_HZ, speed, update, render })
:499-532 update(dt): pause gate β†’ snapshotPositions β†’ (stepGalaxy | tickSelfPlay | tick)
:533-579 render(alpha): tickCamera β†’ drawFrame β†’ drawMinimap β†’ processFrameEvents
β†’ throttled renderHUD/renderObserverPanel (150 ms)
β†’ **over-poll**: if (state.over && !announced) β†’ showGameOver
:581 loop.start(); :582 renderHUD()
boot.js:400 restartToMapSelect() loop.stop, input.destroy, null state/galaxy,
clearPause, renderMapSelect, unhide mapSelect
```
Five other entry points reuse `bootState` verbatim: `startCompetitionMatch` (`:159`),
`startSpectatedMatch` (`:204`), `startScenario`/`startRaider`/`startBounty` (`:234/:242/:250`),
`startOdyssey`β†’`bootGalaxy` (`:259`/`:273`), and `saveload.js`'s load path.
Three structural properties matter for the port:
- **End of match is a *poll*, not an event** (`boot.js:551`: `if (game.state.over && !announced)`).
There is no callback, no state machine. The client discovers the match ended by looking.
- **`bootState` is idempotent teardown-then-setup.** Every "leaving" concern is already
centralised β€” the comment block at `boot.js:390-399` documents exactly why nulling the session
matters (stale hotkeys, the autosave timer writing after "Exit without Saving").
- **The loop reads `game.state` live** (`boot.js:501, 503, 530, 531, 545`), never a captured
binding. Swapping the state under it is already a supported operation (that is how the Odyssey
jump works, `boot.js:365 focusActivePlanet`).
### 2.3 The multiplayer re-shape
The lifecycle gains one screen and loses ownership of two transitions. Concretely:
```
SPLASH β†’ LOBBY β†’ (host: configure | join: wait) β†’ server: MATCH_START
β†’ bootState(receivedSnapshot, {seat}) β†’ loop(render-only) + command submit
β†’ server: MATCH_END β†’ post-match screen β†’ LOBBY
```
**`setup.js` splits in two.** `setup.js:104`'s `setup` object is *match configuration* β€” world,
seed, difficulty, sizeMult, resourceMult, matchTimeLimit, popCap, faction. In MP that object is
the **host's lobby form**, sent to the server once, and echoed back to every client as part of
the match descriptor. The battlefield cards (`setup.js:306 renderMapSelect`) stay verbatim for
the host; joiners see the same cards read-only. `setup.js` keeps its exported option tables
(`MAP_CHOICES:29`, `MATCH_LENGTH_OPTIONS:59`, `POP_CAP_OPTIONS:77`, `STRATEGY_OPTIONS:98`,
`DIFFICULTY_OPTIONS` re-export at `:18`) unchanged β€” they are pure data and the server needs
the same lists to validate a lobby form.
**`startGame` inverts.** `boot.js:109-125` currently *constructs* the state. It becomes:
```
startGame(planetId) β†’ transport.requestMatch(setup) // returns nothing; server decides
server MATCH_START β†’ onMatchStart({ config, seat, seats, snapshot })
β†’ bootState(hydrate(config, snapshot), { intro: true, seat })
```
`hydrate` is cheap because the save format already does it: `engine/persist.js:496 serPlanet`
persists only `(seed, planetId, sizeMult, resourceMult, swapAsym)` plus dynamic entities, and
`rehydratePlanet` regenerates the whole map deterministically. **The client can regenerate
terrain and node geometry itself from the config**; only `node.amount` and the entity tables
need to arrive (`engine/persist.js:549-551` already ships exactly `{id, amount}` per node).
**`bootState` keeps its body almost exactly.** Changes:
- `:485` `createLoop` keeps `render` and *drops* the sim from `update`. The `update` callback
becomes `applySnapshot`/`interpolate`. `snapshotPositions(game.state)` at `:501` must still run
immediately **before** each applied snapshot (it is the interpolation baseline,
`renderShared.js:45`); this is the one ordering constraint that must not be lost.
- `:471` `state.map.bases.player` β†’ `state.map.bases[seat]`.
- `:466` `attachInput(canvas, state, onChange)` gains a `seat` and a `submit` collaborator.
- `:551` the over-poll is replaced by an explicit `MATCH_END` message from the server carrying
`winner`, `winReason`, and per-seat scores (Β§4 explains why scores must be server-computed).
Keep the poll as the loopback path's implementation of the same callback (Β§8).
**`restartToMapSelect` (`boot.js:400`) becomes `leaveMatch`.** Its existing teardown is exactly
right and should be preserved verbatim (it is the file's most carefully-reasoned function). It
gains: close/park the transport, clear `seat`/`seats`, and β€” the new hazard β€” handle *the server
ending the match while the client is mid-teardown*.
**New states with no analogue today:** `LOBBY` (roster, ready-up, seat/faction/colour pick),
`CONNECTING`, `RECONNECTING`, `DESYNCED`, and `OPPONENT_DROPPED`. `pauseReasons`
(`boot.js:94-106`) is a refcounted set and is the right primitive for "the server paused us" β€”
but note that **`pauseLoop` currently gates only the local `update`** (`boot.js:500`). In MP,
pause must come *from* the server; a client-side pause becomes a render-freeze only, and
`togglePause` (`boot.js:103`) must be disabled or converted to a vote. `landingPicker`,
`starmap`, and `techchart` all currently call `pauseLoop` to freeze the world behind a modal
(`boot.js:332`, `starmap.js` tail, `techChart.js` tail) β€” **all three must stop pausing in MP**;
they become non-blocking overlays.
---
## 3. Hardcoded "you are the player" assumptions
~110 literal `"player"`/`"ai"` occurrences outside `engine/`. They fall into five classes.
### 3.1 "Is this entity mine?" β€” the largest class, and the easiest
Every one of these is `owner === "player"` / `owner !== "player"` and becomes
`owner === session.seat`.
| File | Lines |
|---|---|
| `input.js` | `317` (click-select own), `383` (select-all-army), `391` (idle worker), `407` (idle producer), `423` (CC cycle) |
| `inputCommands.js` | `58`, `62` (fog gate on hit-test), `99` (`alivePlayerUnitIds`), `131`, `136`, `170`, `178`, `197`, `210`, `223`, `232`, `242`, `247`, `257`, `264`, `271` β€” the entire right-click router |
| `hud.js` | `47` (gate chip), `137`, `194`, `336` (idle workers), `352` (idle production), `429` (freighter count) |
| `hudPanelSignature.js` | `149`, `234`, `290`, `312` |
| `renderBuildings.js` | `84` (enemy pip), `120`, `147`, `165`, `181`, `233` |
| `renderEffects.js` | `413`, `563` (rally point), `600` (waypoints), `637` (escort links) |
| `renderUnits.js` | `91` (enemy pip) |
| `renderShared.js` | `254` β€” `hiddenByFog`, the one fog predicate; **fix here fixes four draw passes at once** |
| `minimap.js` | `94`, `99` |
| `boot.js` | `370` (`focusActivePlanet` finds own CC), `675` (event fog gate), `684`, `704`, `712` (under-attack trigger), `737` (own supply block) |
| `starmap.js` | `56`, `58` |
| `landingPicker.js` | `60`, `61`, `151`, `160` |
| `techChart.js` | `36` β€” `const OWNER = "player"`, then used at `138`, `153`, `196` |
| `overlays.js` | `68-81` (the eight opening-objective predicates, each `countUnits(state,"player",…)` / `hasCompletedBuilding(state,"player",…)`) |
**Recommendation:** introduce `isMine(e)` in `session.js` and a `SEAT` accessor, then sweep.
This is ~70 one-line edits with excellent test coverage already in place
(`test/input.test.js` 57 tests, `test/renderShared.test.js` 34 tests, `test/hud.test.js` 33).
### 3.2 "The enemy is `"ai"`" β€” the class that genuinely breaks at N seats
These assume **exactly one opponent**, not merely that it is named `"ai"`:
| Site | Assumption |
|---|---|
| `boot.js:684, 704, 712` | `if (ev.owner === "ai") triggerUnderAttack(...)` β€” the comment at `boot.js:669-671` states it outright: *"an attackHit whose attacker is the AI necessarily means the target is the player's (only two sides exist)"*. At 5 seats this fires the under-attack alarm for every fight on the map. **Must become `ev.targetOwner === seat`, which means `engine/combat.js` must start emitting the target owner on `attackHit`.** This is the one place a *sim* change is forced by multiplayer. |
| `hud.js:314` | `playerScore(state, "ai")` β€” the topbar score bar shows "you vs them" |
| `overlays.js:407` | `playerScore(opts.state, "ai")` β€” the game-over breakdown |
| `overlays.js:34` | `FACTIONS[st.players.ai.faction]` β€” the faction chip is a two-sided "you vs them" |
| `overlays.js:376, 392` | `winner === "player" ? victory : defeat` |
| `overlays.js:393-394` | *"Victory β€” the enemy's last Command Center is destroyed"* / *"Defeat β€” your last Command Center was destroyed."* β€” singular "the enemy" |
| `observer.js:290, 291` | `supplyUsed(state,"ai")` / `supplyCap(state,"ai")` |
| `observerPanel.js:89` | `owner => (owner === "player" ? watch.aName : watch.bName)` β€” hard two-entrant |
| `observer.js:116` | `state.map.bases.player \|\| state.map.bases.ai` |
| `hudPanelSignature.js:225-228` | diplomacy signature reads `state.diplomacy` as a single relationship |
The score bar, the faction chip and the game-over copy are all **"two columns" UI**. At N seats
they need to become a small roster table. That is a real (if modest) UI design job, not a rename.
### 3.3 Colours
**Not a problem.** `#4fd1ff` / `#f87171` appear as *entity* colours only in
`engine/state.js:179-180`, as `ownerDefs[].color`. Every renderer reads
`state.players[owner].color` (`renderUnits.js:66`, `renderBuildings.js:75`, `minimap.js:95, 100`),
and `hudSelection.js:1941` / `techChart.js:196` / `landingPicker.js:149` do the same for icons.
The remaining literals in `renderEffects.js:111-113, 315, 326, 447, 511, 542, 584, 616, 680`,
`minimap.js:114`, `renderUnits.js:126`, `renderShared.js:195`, `renderBuildings.js:109, 154, 197`
are **semantic** colours (valid/invalid placement, health tiers, power tiers, tracer types) that
happen to reuse the same hexes. Leave them.
**Action:** extend `ownerDefs` to N entries with a colour ramp; add colours to `style.css`
(3 hits) for the HUD chrome. Nothing else.
### 3.4 `state.fog` vs `state.fogAI`
Already an alias pair over `state.fogs` (`engine/state.js:232-234`, pinned by
`test/ownerScaffold.test.js`). Client reads:
| Site | Read |
|---|---|
| `render.js:202` | `drawFogBase` β€” the charted-space wash |
| `minimap.js:36, 83` | minimap fog underlay + entity gate |
| `renderShared.js:254` | `hiddenByFog` |
| `renderNodes.js:26` | `isNodeDiscovered(state.fog, n)` |
| `renderEffects.js:466` | node picking overlay |
| `inputCommands.js:58, 62, 69` | hit-test + node-pick gating |
| `boot.js:675` | event audibility |
**All nine become `state.fogs[seat]`.** The remaining hazard is *inside the engine*:
`engine/sim.js:70-71` hardcodes `updateFog(state, state.fog, "player")` and
`updateFog(state, state.fogAI, "ai")` even though `state.fogs` exists. That must become
`for (const id of state.owners) updateFog(state, state.fogs[id], id)` β€” which is exactly what
`engine/state.js:270` already does at construction time.
### 3.5 `state.map.bases.player`
`boot.js:371`, `boot.js:471`, `input.js:430`, `observer.js:116`, and inside the engine
`engine/galaxy.js:421, 1365`, `engine/map.js:177, 179, 207`. `engine/aiMilitary.js:554, 574, 589,
694` already indexes generically (`state.map.bases[owner]`). Map generation must produce N start
positions β€” this is a **map-generation change** (`engine/map.js`) and is the single largest
*engine* task implied by N-seat multiplayer. It is out of the client's scope but gates it.
---
## 4. Rendering under authoritative fog
### 4.1 How hard is per-client filtering?
**Much easier than it looks, because the client already renders as if it were filtered.**
`engine/fog.js` is 144 LOC, pure, and already per-owner: `createFog(map)` (`:34`),
`isVisibleAt(fog,x,y)` (`:49`), `isExploredAt` (`:55`), `isNodeDiscovered` (`:66`),
`updateFog(state, fog, owner)` (`:124`). `state.fogs[owner]` exists. There is **no remembered
snapshot of enemy positions** (`engine/fog.js:14-15` says so explicitly) β€” an out-of-vision enemy
simply stops rendering. That is precisely the semantics a filtered projection provides.
So `projectFor(state, seat)` is a ~60-line pure function:
```
units β†’ own βˆͺ { u : isVisibleAt(fogs[seat], u.x, u.y) }, enemy entries stripped
of order/orderQueue/homeCC/target internals (intel leak)
buildings β†’ same rule
nodes β†’ { id, amount } for every charted node; hidden nodes only where
isNodeDiscovered(fogs[seat], n) (engine/fog.js:66)
players β†’ { [seat]: full record } + public facts for the rest
(id, name, colour, faction, score, supply, isAI)
fogs β†’ { [seat]: fogs[seat] }, with `fog` aliased for the legacy readers
owners β†’ unchanged (public)
selection β†’ this seat's own (once it moves off state)
events β†’ the rule already at boot.js:675: own events, plus any event
at a currently-visible point
map β†’ NOT sent; client regenerates from (planetId, seed, sizeMult,
resourceMult, swapAsym) β€” engine/map.js generateMap is deterministic
```
The renderer **needs no changes to consume this**, because every per-entity fog gate
(`renderShared.js:254`, `minimap.js:94, 99`, `renderNodes.js:26`, `inputCommands.js:58, 62`)
simply becomes tautologically true rather than wrong.
### 4.2 What filtering would strip that the client currently needs
Six real dependencies. Five are trivially fixable; one is a genuine visual regression.
1. **`playerScore(state, "ai")`** β€” `hud.js:314` (score bar), `overlays.js:407` (game-over
breakdown). Reads *all* of the enemy's bank/army/structures. β†’ Server computes per-seat scores
and ships them as public facts. `engine/victory.js:158-165` already iterates `ownersOf(state)`.
2. **Enemy faction** β€” `overlays.js:34` reads `st.players.ai.faction` for the "you vs them" chip.
β†’ Public lobby fact; ship in the match descriptor.
3. **Enemy supply** β€” `observer.js:290-291`. Spectator-only; the spectator seat receives an
unfiltered projection.
4. **Enemy CC existence for victory** β€” client never checks this; `engine/victory.js` does, on the
server. No client change.
5. **Hidden-node discovery** β€” `isNodeDiscovered` gates *right-click targetability*
(`inputCommands.js:69`), not just drawing. If the client recomputes fog locally (see below), a
snapshot-rate mismatch could make client and server disagree about whether a cache is
targetable. β†’ Ship an authoritative `discoveredNodeIds` set per seat. It is tiny (caches are
a handful per map, `engine/map.js:266-267`).
6. **Interpolation continuity β€” the real regression.** `renderShared.js:39 prevPos` keys
interpolation baselines by unit id; `renderShared.js:69 pruneFacing` deletes the baseline the
moment a unit is absent from `state.units`. Today an enemy unit that ducks behind fog stays in
the Map (only its *draw* is gated), so it keeps a continuous baseline. Under filtering it
*leaves the map*, loses its baseline and its `facing` entry, and on re-entry snaps rather than
slides. β†’ Give `pruneFacing` a grace window (drop after N frames absent, not immediately).
~5 lines in a file with 34 existing tests.
Everything else the renderer touches is either the client's own entities, immutable map data it
can regenerate, or already fog-gated.
### 4.3 Measured payload (this machine, `engine/persist.js` format)
Full `serializeGameString` snapshot, 15 sim-minutes, both seats AI-driven:
| Map | units / bldgs | full | fog (both) | units | buildings | nodes |
|---|---|---|---|---|---|---|
| Small (1600Γ—1000) | 16 / 16 | 18.9 KB | 3.9 KB | 4.5 KB | 3.7 KB | 4.4 KB |
| Standard 2Γ— (3200Γ—2000) | 27 / 16 | 30.1 KB | 15.6 KB | 6.7 KB | 3.7 KB | 2.4 KB |
| Gigantic 4Γ— (6400Γ—4000) | β€” | 69.6 KB | **62.5 KB** | 3.5 KB | 1.1 KB | 1.3 KB |
**Fog dominates on large maps and entities are tiny.** That single fact drives the recommendation.
### 4.4 Recommendation
> **Ship fog-filtered-per-client, and ship *no fog grid at all*.**
Three parts:
1. **Filter entities server-side** with `projectFor(state, seat)`. This is the anti-cheat
boundary and it must exist before any public or agent-played match. It is ~60 LOC of pure
engine code, directly unit-testable against the existing `test/fog.test.js` fixtures.
2. **Do not transmit the fog grid.** The client already has everything needed to recompute it:
its own units and buildings, and a deterministically regenerated map.
`updateFog(state, myFog, seat)` (`engine/fog.js:124`) is pure, allocation-free, and is exactly
what `engine/sim.js:70` already calls each tick. `explored` accumulates client-side across
snapshots (it is monotonic). This deletes 3.9–62.5 KB from every snapshot and makes payload
scale with *army size* rather than *map size*. The only thing that must remain
server-authoritative is hidden-node discovery (see Β§4.2 item 5), which is a tiny id set.
3. **Keep full-state broadcast as a named, flagged mode** β€” `spectator` and `replay` β€” not as
the default. `observer.js` (Β§7) is already exactly this client, and
`saveShape.js:28 resumableMode` already encodes "a spectated match is not the player's game".
**Trade-off, stated plainly.** Full-state broadcast is ~2 days of work and lets every existing
client module run untouched; it is also trivially cheatable by anyone who opens devtools, and it
is *especially* wrong for this project because MCP agents receive state programmatically β€” an
agent handed the full state is not playing the same game as a human. Filtering costs perhaps a
week (the projection function, per-seat score/faction public facts, the `pruneFacing` grace
window, and the discovered-node channel) and is the only version that survives contact with
public play.
**Migration path.** These are additive and independently shippable:
- **M0** β€” `projectFor(state, seat)` written and tested, but the server still broadcasts full
state. Test: `projectFor(s,"player")` renders pixel-identically to `s` under the existing
render tests. This proves the renderer tolerates a filtered state before anything depends on it.
- **M1** β€” server switches to `projectFor`; fog grid still shipped. Add per-seat public scores
(`hud.js:314`, `overlays.js:407`) and the faction roster (`overlays.js:34`).
- **M2** β€” stop shipping fog; client calls `updateFog` locally on each snapshot. Add the
`discoveredNodeIds` channel. Add the `pruneFacing` grace window.
- **M3** β€” delta-encode entities against the previous acknowledged snapshot. At 16–27 entities,
this is optional until army sizes grow.
---
## 5. What is reusable VERBATIM
The tree is 90,762 LOC total (`.js/.css/.html/.json/.md`); 81,257 LOC of `.js/.css/.html`, of
which **43,101 is tests**. Shipped application code is ~35,400 LOC (`engine` 15,659 + top-level
client 17,903 + css/html 1,815).
### 5.1 Zero change β€” pure, import-free or engine-data-only leaves
| Module | LOC | Why it is untouched |
|---|---|---|
| `sound.js` | 181 | **No imports at all.** Pure WebAudio. Nothing about it is single-player. |
| `effects.js` | 183 | **No imports at all.** Tracers, death flashes, pings, fireworks β€” pure render-side particle bookkeeping. |
| `camera.js` | 105 | **No imports at all.** `createCamera/zoomAt/panCamera/pinchZoomPan/screenToWorld/clampCamera`. |
| `data.js` | 242 | Static tables (planets, commodities, recipes). Pure data. |
| `dom.js` | 68 | Element handles + `MINIMAP_W/H` + `isTouchMode()`. Already Node-import-safe by design (`dom.js:11-18`). |
| `style.css` | 1,732 | 3 hits on the team hexes; add N-seat colour variables. Otherwise verbatim. |
| `saveShape.js` | 31 | Two pure predicates. Gains one term (Β§6). |
| `renderShared.js` | 255 | One line (`:254`). Geometry/colour helpers, interpolation, health bars β€” all generic. |
| `renderNodes.js` | 171 | One line (`:26`). |
| `renderUnits.js` | 699 | Two lines (`:91` enemy pip, `:126` a semantic red). Colour already comes from `players[owner].color` (`:66`). |
| `minimap.js` | 138 | Two lines (`:94, :99`). |
| `render.js` | 277 | `drawFrame` orchestration + `spriteIcon` cache + `drawFogBase`. Only `state.fog` β†’ `state.fogs[seat]` at `:202`. |
| `renderBuildings.js` | 807 | Six `owner !== "player"` lines. Everything else β€” hull art, power tiers, storage bars, jump staging β€” is generic. |
| `renderEffects.js` | 683 | Five `owner !== "player"` lines. |
| `techChart.js` | 366 | One line: `const OWNER = "player"` (`:36`). Plus: stop calling `pauseLoop` in MP. |
| `landingPicker.js` | 211 | Four owner lines. Odyssey-only anyway (Β§7). |
| `elo.js` | 147 | Pure rating math, zero engine/DOM. **Lift to the server verbatim** when MP ranking lands. |
| `pairing.js` | 518 | Pure Swiss/round-robin scheduling; its own header (`pairing.js:15`) notes the single import is `hashStr`. **Lift to the server verbatim.** |
| `version.js` | 60 | Version/save-impact reporting. |
| **Subtotal** | **~6,874** | |
### 5.2 Near-zero change β€” a rename sweep, no restructuring
| Module | LOC | Change |
|---|---|---|
| `overlays.js` | 585 | Owner literals at `:34, :68-81, :376, :392-394, :406-407`; game-over copy becomes N-seat. Toasts/hints/help/objectives verbatim. |
| `hud.js` | 452 | 9 owner literals; score bar becomes a roster; two scenario buttons (`:37, :38`) become commands. |
| `hudPanelSignature.js` | 380 | 4 owner literals + the `players.player` reads. It is a pure signature/diff function β€” the mechanism is untouched. |
| `session.js` | 120 | Additive fields only. |
| `observer.js` + `observerPanel.js` | 550 | Repurposed as the spectator client (Β§7). `enterObserverMode` (`observer.js:128`) gains a third permitted condition. |
| `main.js` | 168 | `:145-151` (minimap right-click) becomes a command submit. Everything else is canvas/DPR/toggles. |
| `starmap.js` | 318 | Odyssey-only; unchanged on the offline path. |
| `update.js` | 108 | Version chip / auto-update. Verbatim. |
| **Subtotal** | **~2,681** | |
### 5.3 Real work
| Module | LOC | Nature |
|---|---|---|
| `boot.js` | 788 | Lifecycle inversion (Β§2). The single heaviest file. |
| `input.js` | 588 | 6 `state.selection` writes, 6 `issue*` calls, 5 owner literals; becomes command-emitting. Gesture/camera/touch code untouched. |
| `inputCommands.js` | 288 | 5 selection writes, 13 `issue*` calls, 16 owner literals. Its own header (`inputCommands.js:15-17`) notes `commandAt` is *already* the "what does this click mean" decision, callable directly β€” **this file is the natural home of client-side command construction.** |
| `hudSelection.js` | 1,998 | ~30 mutator calls + `players.player` reads. But ~900–1,000 LOC of it is Odyssey panels (`renderMarket:346`, `renderFreight:430`, `renderDiplomacy:612`, `renderLanes:717`, `renderColonyPolicy:809`, `renderCapital:890`, `renderSpaceport:1328`, `renderDatacenter:1156`) that are **out of v1 scope and need no change at all**. |
| `setup.js` | 462 | Splits into lobby form + option tables (Β§2.3). |
| `saveload.js` | 371 | Β§6. |
| **Subtotal** | **~4,495** | |
### 5.4 Quantified
Of the **~17,900 LOC of shipped client JS**:
- **~6,900 LOC (β‰ˆ39 %) verbatim or one-line-per-file** (Β§5.1)
- **~2,700 LOC (β‰ˆ15 %) a mechanical owner-literal sweep** (Β§5.2)
- **~4,500 LOC (β‰ˆ25 %) genuine rework** (Β§5.3), of which roughly a fifth is Odyssey panels that
are simply not touched in v1
- **~3,800 LOC (β‰ˆ21 %) out of v1 scope entirely** β€” the competition cluster (Β§7)
Plus **1,732 LOC of CSS effectively verbatim** and **15,659 LOC of engine** that keeps running
unchanged β€” on the server instead of in the tab. The 5 renderer files alone
(`render` + `renderBuildings` + `renderEffects` + `renderNodes` + `renderUnits` +
`renderShared` = **2,892 LOC**) survive with **17 changed lines between them**. That is the
single strongest argument for server-authoritative-with-thin-client over any rewrite.
---
## 6. `saveload.js` / autosave / `localStorage` in multiplayer
### 6.1 What exists
- Two channels (`saveload.js:1-18`): **file** (topbar Save/Load, an explicit `.json`) and
**localStorage autosave** every 12 s (`saveload.js:39`) plus on tab-hide/unload, feeding the
map-select "Continue" buttons.
- Two generations per key (`saveload.js:33-38, 84-98`) so one corrupt write cannot lose a run.
- Keys: `stellarfrontier.save.v1` (`:31`), `stellarfrontier.odyssey.v1` (`:32`).
- The decision of *what is resumable* is a 3-line pure function,
`saveShape.js:28 resumableMode({state, galaxy, spectateMatch})` β€” already unit-tested
(`test/save-shape.test.js`) and already refuses to checkpoint a scenario, a finished match, or
**a spectated match whose player seat isn't really the player's**.
- Other `localStorage` users: `overlays.js:104-108` (objectives-strip dismissed, per mode),
`update.js:23-24` (version-banner dismissed), `competitionLedger.js:1238-1286` (the Elo ledger,
two generations, sanitized on read).
### 6.2 Recommendation
> **Drop client saves for multiplayer entirely. Persist matches server-side using the existing
> format. Record replays as command logs, not snapshots.**
1. **Client autosave off in MP β€” a one-term change.** `saveShape.js:28` already reads
`{ state, galaxy, spectateMatch }`. Add `netMatch`:
`if (!state || state.over || state.scenario || spectateMatch || netMatch) return null;`
That single edit disables autosave, "Save & Exit", and the "Continue" buttons for a networked
match, in a pure function with existing tests. Resuming a *shared* match from one participant's
private browser copy is incoherent, and the localStorage payload is attacker-editable β€” the
file's own header (`competitionLedger.js:11`) already says so.
2. **Server-side match persistence reuses `engine/persist.js` unchanged.** `serializeGameString`
(`:808`) / `deserializeGame` (`:811`) already produce and consume the exact snapshot the server
needs, and `deserializeGame` already treats its input as hostile
(`sanitizeSave`, `engine/persist.js:48-49`, plus `test/save-hardening.test.js` at 836 LOC).
The server checkpoints on the same 12 s cadence; a crash resumes the match. **Zero new format.**
3. **Replays are command logs, not snapshot streams.** The sim is deterministic from
`(config, seed)` and the loop is fixed-step (`engine/loop.js:18-26` explains why the timestep
is the simulation and not a tuning knob). There is already determinism coverage
(`test/determinism.test.js`, `test/determinism-roster.test.js`) and a replay concept
(`boot.js:215-219`'s `recorded`, which lets the game-over screen state honestly whether the
determinism claim held). A `(config, [{tick, seat, envelope}])` log is orders of magnitude
smaller than snapshots, doubles as the anti-cheat audit trail, and is the natural artefact for
an MCP agent to review its own play. **Recording the command log should be built in from day
one** β€” it is nearly free once commands are envelopes, and retrofitting it is not.
4. **Keep client `localStorage` for per-viewer preferences only** β€” `overlays.js:104-108`,
`update.js:23-24`, volume/mute. These are correct as-is.
5. **File Save/Load stays, single-player only.** Hide `saveBtn`/`loadBtn` (`dom.js:42-43`) in a
networked match, exactly as `starmapBtn` is already conditionally hidden.
6. **`competitionLedger.js`'s localStorage Elo ledger is single-player only** and stays that way.
Any MP ladder must be server-side or it is forged by editing a string.
---
## 7. Odyssey / galaxy / competition / observer β€” in or out for v1
| Subsystem | LOC (client + engine) | v1 verdict |
|---|---|---|
| **Skirmish** (the base game) | β€” | **IN** β€” this *is* v1 |
| **Odyssey / galaxy** | `engine/galaxy.js` 1,431 + `colony` 93 + `colonyPolicy` 160 + `diplomacy` 501 + `market` 309 + `wonder` 93 + `starmap.js` 318 + `landingPicker.js` 211 + ~950 of `hudSelection.js` = **β‰ˆ4,066** | **OUT** |
| **Competition / Elo / ladder** | `competition.js` 3,773 + `competitionLedger.js` 1,290 + `competitionWorker.js` 355 + `pairing.js` 518 + `elo.js` 147 + `playerFingerprint.js` 167 + `tools/duelCore.js` 119 + `tools/genome.js` 680 = **7,049** | **OUT as shipped; mine it** |
| **Observer / spectator** | `observer.js` 304 + `observerPanel.js` 246 = **550** | **IN β€” repurposed** |
| **Scenarios** (Escort / Raider / Bounty) | `engine/scenarios.js` 720 | **OUT** (offline path only) |
### Odyssey β€” OUT
Three independent reasons, any one sufficient:
- **It is not a match; it is a save file.** The Odyssey is an open-world sandbox that ticks
*every world in the galaxy each tick* (`boot.js:503 stepGalaxy`), with a single per-player
credit pool, background colonies raising notifications (`boot.js:504 sweepColonies` β†’
`boot.js:605 notifyColony`), and interplanetary relocation that swaps which state is rendered
(`boot.js:365 focusActivePlanet`). "Two players share a galaxy" is an unanswered **game-design**
question, not a porting question.
- **It pauses the world for modals.** `boot.js:332` (landing picker), `starmap.js` (M key), and
`techChart.js` (T key) all call `pauseLoop`. In MP nothing may pause the shared world for one
participant. Every Odyssey overlay would need reworking.
- **It carries most of the client's remaining complexity.** Roughly half of `hudSelection.js`
(the 1,998-LOC file) is Odyssey panels. Excluding Odyssey removes ~950 LOC of panel work from
v1 at zero cost, because those panels never render when `game.galaxy` is null.
**Keep the code in-tree and reachable on the offline path (Β§8). Do not delete it.** Its tests
(`test/odyssey.test.js` 809 LOC, `test/starmap.test.js` 482, `test/landing.test.js` 439,
`test/lanes.test.js`, `test/colonyPolicy.test.js`, `test/livingGalaxy.test.js`,
`test/domination.test.js`, `test/rivalgate.test.js`) keep passing and keep protecting the shared
engine code that skirmish also uses.
### Competition / Elo β€” OUT as shipped, but read it first
`competition.js` (3,773 LOC) is a single-player *ladder screen*: it schedules AI-vs-AI duels,
runs them in a Web Worker (`competitionWorker.js`), and writes ratings into a localStorage ledger.
None of that is multiplayer. But three things in this cluster are the prior art for the port and
should be **read before writing the server**:
- `competitionWorker.js` (355) + `tools/duelCore.js` (119) + `tools/selfplay.js` (169) β€”
**this is already a headless, off-main-thread, deterministic match runner.** It is the server
sim in miniature. `engine/state.js:250-254` documents `state.playerAi`, the second controller
slot that makes owner `"player"` AI-driven β€” the exact hook an MCP agent occupies.
- `pairing.js` (518) and `elo.js` (147) are **pure, engine-free, and directly liftable** to the
server when MP ranking lands. `pairing.js:15` states it holds no engine state at all.
- `playerFingerprint.js` (167) β€” `fingerprintPlayer(state, owner = "player")` (`:53`) and
`mirrorOfPlayer(state, {owner})` (`:162`) are already owner-parameterised. Useful for
agent-vs-human matchmaking later.
Also note the guard at `test/engine-purity.test.js:20-41`: it already extends the determinism scan
to *browser-reachable* `tools/` files precisely because "bench" code started deciding persisted
ratings. **The same guard should be extended to the server's sim entry point on day one.**
### Observer β€” IN, repurposed as the spectator client
This is the highest-leverage "already written" asset in the tree. `observer.js`'s header states
its contract exactly: it *"reveals fog on whichever world you're looking at"*, gives a free camera,
*"deliberately does not touch `game.state`, `game.galaxy.activeId`, or the real input camera"*,
and makes `input.js` refuse to issue a single order β€” every mouse/wheel/key path already
early-returns into `observer.js` while `game.observerMode` is on. **That is a spectator client,
already built and already tested** (`test/observer.test.js`, 23 tests).
The change is one condition. `observer.js:128 enterObserverMode()` currently permits exactly two
cases (an Odyssey, or a watched AI-vs-AI match) and refuses an ordinary skirmish because that
would be a fog cheat. Add a third: *the server assigned this connection the spectator role*. The
server then hands that connection an unfiltered projection, which is legitimate precisely because
a spectator has no seat to cheat with.
Justification for including it in v1: multiplayer needs spectating on day one, and **AI-agent
matches make it a requirement, not a nicety** β€” an MCP agent playing a match is only interesting
if a human can watch it. `observerPanel.js:89`'s two-entrant naming
(`owner === "player" ? watch.aName : watch.bName`) is the only piece needing real work.
---
## 8. Offline / single-player preservation
> **Yes β€” and it should be the primary design constraint, not an afterthought.**
Two reasons. First, the game *is* the single-player game; shipping a port that can only run
against a server throws away a working product. Second and more practically: of ~2,466 tests,
roughly **1,735 are engine/tools tests** that drive `tick()` and `createGameState` directly, and
roughly **731 are client tests** that drive the current client against a locally-constructed
state (`test/input.test.js` 57, `test/hudSelection.test.js` 50, `test/boot.test.js` 29,
`test/hud.test.js` 33, `test/renderShared.test.js` 34, `test/overlays.test.js` 26,
`test/saveload.test.js` 24, `test/observer.test.js` 23 …). **If the client can no longer run its
own sim, every one of those 731 tests needs a mock.** That is the single biggest risk in the port.
### 8.1 Recommendation β€” a loopback transport
Define one interface and give it two implementations:
```
Transport {
submit(envelope) // a command from this client's seat
onSnapshot(fn) // state deliveries
onMatchStart(fn) / onMatchEnd(fn)
seat, seats, role
}
```
- **`LoopbackTransport`** β€” constructs the state in-process with `createGameState`, runs
`createLoop`/`tick` exactly as `boot.js:485-531` does today, and `submit` **applies the command
synchronously, in the caller's stack frame**, by calling the same `issue*` the client calls
today. Behaviourally byte-identical to the current game.
- **`SocketTransport`** β€” serializes envelopes, applies snapshots.
### 8.2 Why this keeps the tests meaningful
The seam is placed at exactly two points, and both preserve today's *observable* semantics:
1. **Command submission.** Because `LoopbackTransport.submit` mutates before returning, every
existing client test that asserts "after this click, the unit has this order" **still passes
unchanged**. This is the whole trick: keep the loopback synchronous.
2. **State delivery.** `boot.js`'s `update` callback (`:499-532`) becomes transport-driven.
`snapshotPositions(game.state)` (`:501`) must run before either a local tick or an applied
snapshot β€” one ordering rule, easy to pin with a test.
Add one new engine function, `applyCommand(state, seat, envelope)`, that resolves ids β†’ entities,
**filters to `seat`-owned entities**, and calls the unchanged `issue*`. Then:
- `test/commands.test.js` (503 LOC) keeps testing `issue*` directly β€” untouched.
- One new test file pins envelope β†’ `issue*` and the ownership filter (which is *new* safety
the game has never had: today nothing stops a client from ordering enemy units, because there
is no adversary).
- `test/input.test.js`, `test/hudSelection.test.js`, `test/boot.test.js` keep working against the
loopback.
**The one unavoidable test churn is `state.selection`** (Β§1.3): 124 test references. Moving it to
the client session is mechanical but wide. Budget for it explicitly; do it as its own change,
before the transport work, so the diff is reviewable.
### 8.3 What single-player retains
With a loopback transport, **the full offline game keeps working**: skirmish, all three scenarios
(`boot.js:234, 242, 250`), the whole Odyssey (`boot.js:259`), the competition ladder and its Web
Worker duels, file save/load, and localStorage Continue. `saveShape.js:28`'s `netMatch` term
(Β§6.2) is exactly what keeps the two worlds from contaminating each other. The Hugging Face Space
can therefore serve one bundle that plays offline *and* connects β€” and the offline path is also
the fastest development loop for anyone working on the renderers or the HUD.
---
## 9. Summary table
Verdicts: **verbatim** (0–2 changed lines) Β· **light** (a rename sweep, no restructuring) Β·
**heavy** (real rework) Β· **server-side** (moves out of the browser) Β·
**out-v1** (untouched, offline-only)
| Module | LOC | Verdict | Why |
|---|---|---|---|
| `engine/` (all 49 files) | 15,659 | **server-side** | Runs headless. Needs: `applyCommand` dispatcher, `projectFor(state, seat)`, N-entry `ownerDefs` (`state.js:174`), owner-generic `updateFog` loop (`sim.js:70`), N start bases (`map.js`), `attackHit` target owner (`combat.js`) |
| `style.css` | 1,732 | **verbatim** | 3 team-colour hits; add N-seat variables |
| `index.html` | 83 | **light** | Add a lobby container; hide save/load in MP |
| `sound.js` | 181 | **verbatim** | Zero imports |
| `effects.js` | 183 | **verbatim** | Zero imports |
| `camera.js` | 105 | **verbatim** | Zero imports |
| `data.js` | 242 | **verbatim** | Static tables |
| `dom.js` | 68 | **verbatim** | Already Node-safe (`dom.js:11-18`) |
| `version.js` | 60 | **verbatim** | |
| `update.js` | 108 | **verbatim** | Self-wired version chip |
| `saveShape.js` | 31 | **verbatim** | +1 term: `netMatch` (`:28`) |
| `renderShared.js` | 255 | **verbatim** | `:254` only; + `pruneFacing` grace window (Β§4.2) |
| `renderNodes.js` | 171 | **verbatim** | `:26` only |
| `minimap.js` | 138 | **verbatim** | `:94, :99` |
| `render.js` | 277 | **verbatim** | `:202` (`state.fog` β†’ `fogs[seat]`) |
| `renderUnits.js` | 699 | **verbatim** | `:91`; colour already generic at `:66` |
| `renderBuildings.js` | 807 | **verbatim** | 6 owner lines; colour generic at `:75` |
| `renderEffects.js` | 683 | **verbatim** | 5 owner lines |
| `techChart.js` | 366 | **verbatim** | `:36` `OWNER`; stop pausing in MP |
| `elo.js` | 147 | **server-side** | Pure; lift verbatim when ranking lands |
| `pairing.js` | 518 | **server-side** | Pure; lift verbatim when ranking lands |
| `playerFingerprint.js` | 167 | **out-v1** | Already owner-parameterised (`:53, :162`); useful later |
| `session.js` | 120 | **light** | Additive: `seat`, `seats`, `role`, `transport`, `selection` |
| `main.js` | 168 | **light** | `:145-151` minimap command β†’ submit |
| `hud.js` | 452 | **light** | 9 owner literals; score bar β†’ roster; `:37, :38` β†’ commands |
| `hudPanelSignature.js` | 380 | **light** | 4 owner literals + `players.player` reads |
| `overlays.js` | 585 | **light** | `:34, :68-81, :376, :392-394, :406-407`; N-seat game-over copy |
| `observer.js` | 304 | **light** | `:128` gains a third permitted case β†’ the spectator client |
| `observerPanel.js` | 246 | **light** | `:89` two-entrant naming β†’ N-seat |
| `inputCommands.js` | 288 | **heavy** | 13 `issue*` + 5 selection writes + 16 owner literals; becomes the command constructor |
| `input.js` | 588 | **heavy** | 6 `issue*` + 6 selection writes + 5 owner literals; gesture/camera/touch untouched |
| `setup.js` | 462 | **heavy** | Splits: lobby form + (verbatim) option tables |
| `saveload.js` | 371 | **heavy** | MP: off. SP: unchanged. Server reuses `engine/persist.js` |
| `boot.js` | 788 | **heavy** | Lifecycle inversion; the single biggest file of real work |
| `hudSelection.js` | 1,998 | **heavy** (~1,050) / **out-v1** (~950) | ~30 mutator calls in the skirmish half; the Odyssey panels are untouched |
| `starmap.js` | 318 | **out-v1** | Odyssey |
| `landingPicker.js` | 211 | **out-v1** | Odyssey |
| `competition.js` | 3,773 | **out-v1** | Single-player ladder screen |
| `competitionLedger.js` | 1,290 | **out-v1** | localStorage Elo ledger β€” forgeable, SP-only |
| `competitionWorker.js` | 355 | **out-v1** | Read as prior art for the headless server sim |
| `tools/` (`selfplay`, `duelCore`, `genome`, `ailab`, `serve`, `smoke`) | 3,593 | **out-v1** | `selfplay.js`/`duelCore.js` are the closest existing thing to the server runner |
| `test/` | 43,101 | **preserve** | ~1,735 engine tests unaffected; ~731 client tests preserved by the synchronous loopback (Β§8) |
**Totals:** verbatim/near-verbatim β‰ˆ **6,900 LOC (39 % of shipped client JS)** Β·
light sweep β‰ˆ **2,700 (15 %)** Β· heavy β‰ˆ **4,500 (25 %)** Β· out-v1 β‰ˆ **3,800 (21 %)** Β·
plus 15,659 LOC of engine relocated intact and 1,732 LOC of CSS untouched.
---
## 10. Ordered recommendations
1. **Move `state.selection` off the sim state first.** 10 write sites, 32 client reads, 2 engine
reads, 124 test references. It is the widest mechanical change and it is independent of
everything else β€” do it as its own reviewable diff, before the transport.
2. **Add `applyCommand(state, seat, envelope)` to `engine/commands.js`**, with the
**seat-ownership filter** that does not exist today. Keep every `issue*` unchanged so
`test/commands.test.js` is untouched.
3. **Introduce `Transport` with `LoopbackTransport` first.** Land the whole port against
loopback before a socket exists. The client tests are the regression suite.
4. **Sweep the owner literals** behind `session.seat` / `isMine(e)` (Β§3.1). ~70 one-liners with
heavy existing coverage.
5. **Make the engine N-seat**: `ownerDefs` (`engine/state.js:174`), the `updateFog` loop
(`engine/sim.js:70`), N start bases (`engine/map.js`), and `attackHit`'s target owner
(`engine/combat.js`). `test/ownerScaffold.test.js` is the spec that already exists.
6. **Write `projectFor(state, seat)` and prove it renders identically to the full state**
before anything depends on it (M0 in Β§4.4).
7. **Record the command log from day one.** It is nearly free once commands are envelopes, it
is the replay format, the anti-cheat audit trail, and the MCP agent's trace.
8. **Extend `test/engine-purity.test.js`'s reachability walk to the server entry point** the
moment it exists. The guard's own comment (`:20-41`) explains what happened last time a
"bench" file started deciding real outcomes.
9. **Defer Odyssey and the competition ladder; keep both alive on the loopback path.** They cost
nothing to keep and protect ~1,000 tests over shared engine code.
10. **Ship the spectator seat in v1** by widening `observer.js:128`. It is already written,
already tested, and AI-agent matches make it a requirement rather than a nicety.