Spaces:
Paused
Paused
|
Download docs/analysis/03-client-coupling.md from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 59.1 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/analysis/03-client-coupling.md
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/docs/analysis/03-client-coupling.md
-
curl -L -o 03-client-coupling.md https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/analysis/03-client-coupling.md
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. | |