Spaces:
Paused
Paused
File size: 70,665 Bytes
ef7537c a4e3249 ef7537c a4e3249 a75dff0 a4e3249 a75dff0 a4e3249 a75dff0 a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 a75dff0 a4e3249 a75dff0 a4e3249 a75dff0 a4e3249 a75dff0 a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a75dff0 a4e3249 ef7537c a4e3249 ef7537c a4e3249 a75dff0 ef7537c a75dff0 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 a75dff0 a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 a75dff0 a4e3249 ef7537c a4e3249 ef7537c a4e3249 a75dff0 a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 a75dff0 a4e3249 a75dff0 a4e3249 a75dff0 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 ef7537c a4e3249 a75dff0 a4e3249 ef7537c a75dff0 a4e3249 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 | # 03 β Client coupling: what must change to become a networked client
**Status:** analysis, decisive. Input to `docs/adr/` and `TASKS.md`; not a specification.
**Tree audited:** `/home/user/SpaceCities` @ working tree (the imported-verbatim upstream, ADR-0002).
All `file.js:line` citations are against that tree and are exact at time of writing β cited so a
reader can confirm a claim, not as a patch plan.
**Peers:** [`01-engine-nplayer-seams.md`](01-engine-nplayer-seams.md) owns everything inside
`engine/`; [`02-command-wire-protocol.md`](02-command-wire-protocol.md) owns the envelope, codec and
anti-cheat. This dossier stays on the **client** side of that line: the ~17.9k JS + 1.7k CSS lines
outside `engine/`.
**Architecture assumed (decided):** server-authoritative headless Node sim (ADR-0003); single-player
runs the same path through an in-process loopback transport (ADR-0004).
---
## 0. Headline findings
1. **The client mutates the simulation in exactly 64 places, in 6 files.** 26 `issue*` calls,
38 non-`issue*` engine mutators. Every one already takes an explicit `(state, id, args)`-ish
shape β they are command envelopes that were never serialized. Dossier 02's D1 ("wrap, do not
rewrite") is what makes this a re-seaming job.
2. **Only two client lines write a simulation field directly, bypassing `engine/` entirely** β
`hudSelection.js:1045` (`e.homeCC = null`) and `hudSelection.js:1722` (`e.electrified = v`).
Neither goes through `engine/commands.js` today. The first is nonetheless free to fix:
`issueSetHomeBase(units, ccId)` already exists (`engine/commands.js:281`) and accepts `null`, so
the HUD is bypassing a command that would have done the job. The second is a genuine gap β
**`electrified` has two direct writers and zero commands**, the HUD and `engine/aiIndustry.js:172`
(`engine/industry.js:38` documents the duplication outright). These are the only two holes in an
otherwise correctly-placed trust boundary, and closing them is the single most actionable item
in this document.
3. **No client file writes `resources` or `credits`.** Verified by grep: every economic mutation
goes through an engine function. The trust boundary is already drawn in the right place; it is
simply not enforced. What the client *does* do is **read** the local seat's economy β
`state.players.player.resources` at 7 sites in `hudSelection.js` alone, 15 across the client β
a read that per-seat filtering must keep serving (it always can: your own economy is never fogged).
4. **The renderers are already owner-generic where it counts.** Team colour is read as
`state.players[owner].color` at all four draw sites (`renderUnits.js:66`, `renderBuildings.js:75`,
`minimap.js:95`, `minimap.js:100`); `#4fd1ff`/`#f87171` live in `engine/state.js:179-180` as
*data*, and every client occurrence of those literals is unrelated UI chrome. The fog rule is
factored into one named predicate, `renderShared.js:253 hiddenByFog`. "Seat 3 of 5" does not
break rendering β and neither does fog filtering: the frame interpolator is **already** safe
against entities appearing and vanishing between snapshots (Β§4.2 item 6), which is normally the
first thing that breaks when a client stops holding the whole world.
5. **What "seat 3 of 5" actually breaks is 119 owner-literal sites outside `engine/`** β 68
comparisons, 29 owner arguments passed to already-parameterized engine functions, and 22
hardcoded `state.players.player`/`.ai` property paths. (Counting `tools/` and `competition.js`
too, the comparison total is 80, matching dossier 01's finding #5.) **110 of the 119 are the same
assertion β "`player` means me" β and collapse to one `state.localOwner` seam.** The remaining
**9** are the harder assertion, "the enemy is `ai`": genuine 1v1 semantics that must be
redesigned or scoped out. Β§3.2 lists all nine.
6. **Broadcasting full state is not affordable, and the numbers are not close.** Measured on this
machine with `engine/persist.js`: a 4Γ map with 800-vs-800 units serializes to **404 KB in
4.45 ms**; at 20 Hz Γ 4 seats that is 32 MB/s and 18 ms of a 50 ms tick budget spent on
`JSON.stringify` alone. A per-seat fog-filtered delta of the same state is **55 KB in 0.69 ms**.
Dossier 00's "the sim runs ~500Γ faster than real time" is confirmed from the other side:
**serialization is the server cost, and filtering is what makes it affordable, not what makes it
expensive.** Recommendation: **fog-filtered per client, from day one.** Β§4.
7. **The map never has to cross the wire.** `serializeGame` does not include `state.map` at all
(measured: `map` is absent from the payload's top-level keys) β terrain, node positions and base
positions are regenerated from `seed` + `planetId` on load (`engine/persist.js` `rehydratePlanet`).
A client that is told `{planetId, seed, sizeMult, resourceMult}` at join reconstructs the entire
static world locally. That is the largest single replication saving available and it is already
built.
8. **~45% of the client is verbatim or a one-line-per-site substitution.** Counting the modules that
ship in multiplayer v1, **68% of the in-scope 12,856 LOC needs zero or near-zero change**. The
real work is concentrated in four files totalling 3,662 LOC β `boot.js`, `hudSelection.js`,
`input.js`, `inputCommands.js` β and inside three of those four the touched lines are under 15%
of the file. Β§5.
---
## 1. The local-authority inventory
Every place outside `engine/` that mutates simulation state, or assumes it owns the simulation.
### 1.1 `engine/commands.js` `issue*` β direct order issuance (26 sites, 4 files)
Imports: `inputCommands.js:27`, `hudSelection.js:21`, `main.js:21`, `input.js:12`.
| # | Site | Call | Notes for the codec |
|---|---|---|---|
| 1 | `inputCommands.js:94` | `issueAttackMove(combatants, x, y, queue, formation)` | right-click; split from :95 by role |
| 2 | `inputCommands.js:95` | `issueMove(others, x, y, queue, formation)` | same click, second envelope β see Β§1.6 |
| 3 | `inputCommands.js:199` | `issueSetRally(building, p.x, p.y, node?.id)` | **the one engine signature change** (02 D2): bare `Building` ref, no `state` |
| 4 | `inputCommands.js:212` | `issueAssistBuild(workers, target.id, target.type, queue)` | `target.type` is client-supplied β 02 flags it |
| 5 | `inputCommands.js:226` | `issueServiceBuilding(workers, target.id, queue)` | |
| 6 | `inputCommands.js:235` | `issueRepair(workers, target.id, queue)` | building target |
| 7 | `inputCommands.js:245` | `issueSetHomeBase(eligible, target.id)` | |
| 8 | `inputCommands.js:249` | `issueAttack(attackers, target.id, queue)` | |
| 9 | `inputCommands.js:259` | `issueFerryFreighter(workers, target.id, queue)` | |
| 10 | `inputCommands.js:266` | `issueRepair(workers, target.id, queue)` | unit target |
| 11 | `inputCommands.js:273` | `issueEscort(escorts, target.id, queue)` | **array order is the ring slot** (02 D4) |
| 12 | `inputCommands.js:278` | `issueGather(workers, node.id, queue)` | |
| 13 | `inputCommands.js:282` | `issueMove(selected, p.x, p.y, false, formation)` | |
| 14 | `hudSelection.js:518` | `issueSetAILogistics([f], !on, state)` | reads `state` for the tech gate |
| 15 | `hudSelection.js:542` | `issueSetCollectPoint([f], !on)` | |
| 16 | `hudSelection.js:600` | `issueSetLogiPriority(state, b.id, β¦)` | already id-based |
| 17 | `hudSelection.js:1821` | `issueCancelRecycle(recyclingNow)` | |
| 18 | `hudSelection.js:1835` | `issueRecycle(recyclable)` | banks resources later (02 #17) |
| 19 | `main.js:149` | `issueAttackMove(combatants, world.x, world.y)` | minimap right-click |
| 20 | `main.js:150` | `issueMove(others, world.x, world.y)` | same click |
| 21 | `input.js:346` | `issueStop(selectedUnits())` | |
| 22 | `input.js:351` | `issueScout(selectedUnits())` | |
| 23 | `input.js:355` | `issueHold(selectedUnits())` | |
| 24 | `input.js:372` | `issuePatrol([u], points)` | **unbounded `points`** (02 #20) |
| 25 | `input.js:379` | `issueHoldFormation(selectedUnits(), shape, leaderPos)` | anchor is the live centroid |
| 26 | `input.js:521` | `issueBuild(state, worker.id, type, p.x, p.y)` | **return value consumed** β Β§1.6 |
### 1.2 Non-`issue*` engine mutators called from the client (38 sites, 3 files)
Dossier 02's scope correction is confirmed and this is where the money is: **every cost-bearing
action** (production, research, market, diplomacy, colony, galaxy) goes through this group, not
through `commands.js`.
| Family | Sites | Engine module |
|---|---|---|
| Production / research | `hudSelection.js:197` `cancelProduction`, `:235` `cancelResearch`, `:1098`, `:1104`, `:1198`, `:1629` `queueProduction`, `:1149` `researchUpgrade`, `:1179` `researchTech` | `production.js`, `techtree.js` |
| Market (spends credits) | `hudSelection.js:381`, `:395`, `:405` `sell`, `:414` `buy` | `market.js` |
| Freight | `hudSelection.js:449`, `:485` `loadFreighter`; `:459`, `:494` `unloadFreighter` | `galaxy.js` |
| Diplomacy | `hudSelection.js:630` `offerTribute`, `:661` `fulfillRequest`, `:705` `offerGift` | `diplomacy.js` |
| Lanes | `hudSelection.js:766` `unassignShipFromLane`, `:777` `assignShipToLane`, `:781` `deleteLane`, `:793` `createLane` | `galaxy.js` |
| Colony policy | `hudSelection.js:831`, `:837`, `:850`, `:870`, `:876`; `starmap.js:229`, `:239`, `:244` `setColonyPolicy` | `colonyPolicy.js` |
| Colony / capital | `hudSelection.js:900` `upgradeToCapital`, `:905` `packCommandCenter`, `:1750` `deployColonyShip` | `galaxy.js`, `colony.js` |
| Spaceport | `hudSelection.js:1386` `upgradeSpaceport` | `galaxy.js` |
| Bomb | `hudSelection.js:1455` `lightFuse(state, bomb)` | `bomb.js` β takes a live object |
| Scenario | `hud.js:37` `repairConvoy(game.state)`, `hud.js:38` `departNow(game.state)` | `scenarios.js` |
**Two of these are Odyssey/galaxy-only** (lanes, colony policy, freight, spaceport, capital) and
therefore out of multiplayer v1 by Β§7 β which removes 22 of the 38 from the v1 codec surface.
### 1.3 Direct `state.*` writes from the client
There are exactly four kinds, and they are not all the same severity.
| Kind | Sites | Verdict |
|---|---|---|
| `state.selection = β¦` | `inputCommands.js:147`, `:152`, `:154`, `:157`, `:177`; `input.js:318`, `:337`, `:382`, `:395`, `:411`, `:543` (**11**) | **Not sim state.** `engine/persist.js` resets it to `[]` on load and dossier 02 D6 moves it to the client session. Zero wire cost. |
| `state.events.length = 0` | `boot.js:766` | **The client drains the sim's event queue.** In multiplayer, events must be produced server-side, fog-filtered per seat (`boot.js:675` already applies the fog rule), replicated, and drained *client-locally*. See Β§4.2. |
| Galaxy notification drains | `boot.js:511` `pacifyNotes.length = 0`, `:517` `milestones.length = 0`, `:522` `reliefNote = false` | Odyssey-only β out of v1 (Β§7). Same pattern as the event drain. |
| **Raw entity-field writes** | `hudSelection.js:1045` `e.homeCC = null`; `hudSelection.js:1722` `e.electrified = v` | **The only true trust-boundary holes.** Neither goes through `engine/commands.js`. `:1045` is free to fix β `issueSetHomeBase(units, ccId)` (`engine/commands.js:281`) already accepts `null`. `:1722` needs a new command. Fix both before the codec ships. |
`hudSelection.js:1722` is the worse of the two, for two reasons. It writes `electrified` on
**every** building in the current selection, with only a client-side `e.owner === "player"` filter
standing between it and electrifying an opponent's Habitat β and under server authority that filter
disappears along with the client that enforced it. And `electrified` has **no command anywhere**:
`engine/aiIndustry.js:172` sets it directly too, which `engine/industry.js:38` documents as
deliberate (*"engine/aiIndustry.js, which flips `electrified` on its buildings the same way the HUD
does"*). A new `issueSetElectrified(state, buildingIds, on)` should become the single writer, with
the AI routed through it as well.
### 1.4 `state.players.player.β¦` β direct owner-keyed reads (22 sites + 1 optional-chained)
No writes; all reads. They matter because each is a place that must be re-pointed at the local seat
*and* must survive fog filtering (the local seat's own economy is always fully visible, so filtering
never strips these β but they hard-code the key).
- **`state.players.player.resources`** β `hudSelection.js:272`, `:363`, `:432`, `:522`, `:651`,
`:680`, `:1955`; `hudPanelSignature.js:146`, `:227`, `:228`, `:264`, `:272`, `:344`;
`hud.js:99`, `:174`
- **`.upgrades`** β `hudSelection.js:508`, `:1122`, `:1157`; `hudPanelSignature.js:180`
- **`.color`** β `hudSelection.js:1941`; `landingPicker.js:149` (optional-chained:
`dest.players?.player?.color || "#4fd1ff"` β the one place a hardcoded owner *and* a hardcoded
colour appear on the same line; Odyssey-only, out of v1)
- **`.faction`** β `overlays.js:34` (`st.players.player.faction` **and** `st.players.ai.faction` in
one line β a 1v1 assumption, not just a local-seat one)
- **Already owner-generic** β `techChart.js:138`, `:153`, `:196` via `const OWNER = "player"`
(`techChart.js:36`); `hudPanelSignature.js:373` via `state.players[b.owner]`;
`minimap.js:95`, `:100`, `renderUnits.js:66`, `renderBuildings.js:75` via `state.players[e.owner]`
- **Enemy-keyed** β `observer.js:276` `state.players.ai`
> **Trap.** `data.js:124` defines a commodity whose id is literally `"ai"`
> (`{ id: "aifab", out: "ai", β¦ }` β AI cores). `hudPanelSignature.js:272` reads
> `state.players.player.resources.ai`. **A blind rename of the owner id `"ai"` will corrupt the
> economy.** Any owner-literal sweep must be site-by-site.
### 1.5 `createGameState` / `tick` / `createLoop` β the sim ownership itself
All in `boot.js`. This is the part that genuinely moves to the server.
| Site | Call | Moves to |
|---|---|---|
| `boot.js:119` | `createGameState({planetId, seed, rng, aiApm, β¦})` (skirmish) | server, from the lobby's agreed config |
| `boot.js:169` | `createGameState(β¦)` (competition fixture) | out of v1 (Β§7) |
| `boot.js:207` | `createSelfPlayState(β¦)` (watched duel) | out of v1 / server-side spectate |
| `boot.js:236`, `:244`, `:252` | `setupEscort` / `setupRaider` / `setupBounty` | server (single-player scenarios via loopback) |
| `boot.js:262` | `createGalaxy(β¦)` | out of v1 (Β§7) |
| `boot.js:287` | `jumpCapital(game.galaxy, destId, β¦)` | out of v1 |
| `boot.js:344` | `surrenderGalaxy(game.galaxy)` | out of v1 |
| `boot.js:485` | `createLoop({hz, speed, update, render})` | **splits.** The *sim* half (`update`) moves to the server; the *render* half (`render`) stays and becomes a plain `requestAnimationFrame` interpolator |
| `boot.js:503-504` | `stepGalaxy` / `sweepColonies` | out of v1 |
| `boot.js:530` | `tickSelfPlay(game.state, dt)` | out of v1 |
| `boot.js:531` | `tick(game.state, dt)` | **server** |
`engine/loop.js` is a fixed-timestep accumulator with a render callback and an `alpha` interpolation
fraction. Server-side it keeps the accumulator and drops the render callback; client-side the render
callback survives essentially unchanged because `render.js` already interpolates between two
positions per entity (`snapshotPositions`, `boot.js:501`) β the exact mechanism a networked client
needs for snapshot interpolation. **That is a significant unplanned gift.**
### 1.6 Return values the client consumes β the optimistic-update hazards
Server authority makes every mutation asynchronous. These sites read a synchronous answer *now*:
| Site | Pattern | Why it breaks |
|---|---|---|
| `input.js:521-523` | `const built = worker && issueBuild(...); if (built) buildMode = null; else sound.playProductionBlocked();` | Build mode exits (and the ghost clears) **only on a successful placement**. The client must either predict the placement locally and roll back on a server NACK, or keep the ghost up until an ack β a visible latency change either way. This is the highest-visibility UX consequence in the whole port. |
| `hudSelection.js:449`, `:459`, `:485`, `:494` | `if (loadFreighter(...) > 0) any = true` | Loop over commodities, branching on how much actually moved. Out of v1 with Odyssey, but the pattern recurs. |
| `hud.js:37` | `if (repairConvoy(game.state)) renderHUD()` | Repaints only if the repair took. |
| `boot.js:287-290` | `const result = jumpCapital(...); if (!result) return null; focusActivePlanet();` | Out of v1. |
| `inputCommands.js` (throughout) | each handler `return`s `true`/`false` to mean "this click was consumed" | **Benign.** The consumption decision is a *client-side hit test*, not a sim answer; it can stay synchronous. |
Everything else fires and forgets, followed by `renderHUD()` β which under authority just becomes
"repaint on the next snapshot", i.e. strictly simpler.
---
## 2. `session.js` / `boot.js` β the lifecycle, and how it re-shapes
### 2.1 `session.js` today (120 LOC, zero imports)
A single exported mutable object, `game`, holding 16 fields. It has **no imports at all** and
contains no logic β it exists so `hud`/`boot`/`save`/`input` all see the same current game across a
restart, and everything reads it *at call time* rather than destructuring at module scope
(`session.js:8-10`). That discipline is why a networked rewire is cheap: swapping what `game.state`
points at is already the supported operation.
Fields, grouped by what happens to them:
| Group | Fields | Fate |
|---|---|---|
| **The sim handles** | `state` (`:16`), `galaxy` (`:21`) | `state` becomes a **replicated view**, not the authority. `galaxy` out of v1. |
| **Local input/UI** | `input` (`:17`), `formation` (`:75`), `collapsedSections` (`:84`), `groups` (`:69`) | **Verbatim.** All already documented as "never part of the deterministic sim". |
| **Cross-module UI bookkeeping** | `supplyBlockedUntil` (`:34`), `lastAttackAt` (`:40`), `lastGateCharge` (`:52`), `colonyAlerts` (`:64`) | Verbatim; driven by replicated events instead of local ones. |
| **Mode flags** | `competition` (`:30`), `spectateMatch` (`:114`), `spectateSpeed` (`:119`) | Out of v1 (Β§7). |
| **Observer** | `observerMode` (`:89`), `spectateId` (`:92`), `spectatePreview` (`:99`), `observerCamera` (`:103`) | **Kept and repurposed** (Β§7). |
**The re-shape is additive.** `session.js` gains a small, well-defined set of new fields and loses
nothing that v1 ships:
```js
// new in session.js
transport: null, // LoopbackTransport | WebSocketTransport (ADR-0004)
match: null, // { matchId, seats:[{owner,name,kind,color}], mapCfg, hostSeat }
localOwner: null, // MY seat id β the single seam Β§3 collapses into
serverTick: 0, // last applied authoritative tick
lobby: null, // pre-match lobby model (setup.js renders it)
netStatus: "offline", // offline | connecting | live | lagging | resyncing | dropped
```
`localOwner` is the load-bearing one. `state.localOwner` (or `game.localOwner`) is what turns 110 of
the 119 owner-literal sites (Β§3) into one substitution.
### 2.2 `boot.js` today β the lifecycle, one line each
- **Splash β map select.** `main.js:165` calls `renderMapSelect()` at module-eval time. `setup.js`
owns the screen; card click β `startGame(planetId)` (or a scenario / Odyssey / Continue variant).
- **`startGame(planetId)`** β `boot.js:109`. Draws a seed (`resolveSeed`, `:64`), resolves difficulty
dials (`:54`), picks the AI's faction from the world archetype (`:118`), calls
`createGameState` (`:119`), hands off to `bootState(fresh, {intro:true})` (`:124`).
- **`bootState(newState, {intro, selfPlay})`** β `boot.js:443`. **The single funnel every start path
uses.** In order: stop the old loop (`:444`), destroy old input (`:445`), exit observer mode
(`:446`), hide map-select / game-over / under-attack (`:447-450`), clear mode flags and per-game UI
(`:453-457`), **assign `game.state`** (`:458`), toggle the scenario body class (`:462`), show
seed + faction chips (`:463-464`), objectives strip (`:465`), `attachInput` (`:466`), park the
camera on the player's base (`:471-475`), reset effects/facing/panel-signature/world bookkeeping
(`:476-481`), clear pause (`:482`), **`createLoop`** (`:485`), `loop.start()` (`:581`),
`renderHUD()` (`:582`).
- **The loop's `update`** β `boot.js:499-532`. Skip if paused (`:500`); `snapshotPositions` (`:501`);
then one of three branches: galaxy (`:502-524`), spectated self-play (`:525-530`), or plain
`tick(game.state, dt)` (`:531`).
- **The loop's `render`** β `boot.js:533-579`. `tickCamera`; pin `alpha` to 1 while paused (`:540`);
choose `viewState`/`viewCamera` (observer or live, `:545-546`); `drawFrame` (`:547`);
`drawMinimap` (`:548`); `processFrameEvents()` (`:549`); throttled `renderHUD` every 150 ms
(`:550`); **then the over-poll** (`:551`).
- **Win / lose.** `boot.js:551`: `if (game.state.over && !announced)` β `announced = true`,
`loop.stop()` (`:553`), then `showScenarioEnd` or `showGameOver(winner, seed, restartToMapSelect,
{β¦})` (`:560-577`). The client **polls a boolean the sim set** β it does not decide.
- **Restart.** `restartToMapSelect()` β `boot.js:400`. Stop loop, destroy input, exit observer,
null `state`/`galaxy`/`competition`/`spectateMatch`, repaint observer UI, clear pause, hide the
pause button, `renderMapSelect()`, unhide map-select. Documented as idempotent (`:399`).
- **Pause.** Refcounted reasons (`boot.js:94-107`) β Help, the Home-confirm modal, the landing
picker, and a manual `P`. **Gates `update()` only; render keeps drawing.**
### 2.3 The multiplayer re-shape, precisely
The shape of `bootState` is exactly right and should be preserved. What changes is **who calls it,
with what, and who decides it is over**.
| Today | Networked |
|---|---|
| `renderMapSelect()` at `main.js:165` | unchanged β the splash is still local |
| Card click β `startGame(planetId)` | Card click β `createMatch(cfg)` / `joinMatch(code)` β **lobby screen** |
| β | **`renderLobby()`** (new, in `setup.js`): seat list, per-seat ready flags, kind (human / AI / MCP agent), colour, faction, host-only map dials. Driven by a replicated `lobby` snapshot; the host's dial changes are commands like any other. |
| `resolveSeed(setup)` at `boot.js:114` β client draws the seed with `Math.random` | **server draws the seed** and announces it in the match-start message. The client's `resolveSeed` survives for single-player-over-loopback only. |
| `createGameState(β¦)` at `boot.js:119` | **server** constructs it from the lobby config; the client receives `{matchId, seats, planetId, seed, sizeMult, resourceMult, matchTimeLimit, popCap, swapAsym}` and rebuilds the *static* world locally (Β§0.7) |
| `bootState(fresh, {intro:true})` | `bootState(view, {intro, seats, localOwner})` β **same function**, called from the transport's `match-start` handler instead of from a click handler. Everything from `:444` to `:482` is unchanged. |
| `loop = createLoop({update: dt => tick(state,dt), render})` | `createLoop({update: **applyPendingSnapshot + advanceInterpolation**, render})`. The render half at `:533-579` is untouched. `hz` becomes the snapshot cadence, not the sim rate. |
| `snapshotPositions(game.state)` at `:501` | **unchanged and now load-bearing** β it is already the interpolation baseline; it becomes the previous *authoritative* snapshot. |
| `processFrameEvents()` at `:549`, draining `state.events` | drains the **replicated, per-seat-filtered** event list the snapshot carried |
| `if (game.state.over && !announced)` at `:551` | **replaced by a server `match-end` message.** Polling a local boolean is wrong under authority: a client whose connection dropped would poll a stale `over:false` forever, and a modified client could set `over` to skip the screen. The `showGameOver(...)` call at `:561` keeps its exact argument shape β `winner`, `seed`, `winReason`, `state` all arrive in the message. |
| `restartToMapSelect()` at `:400` | keeps its whole teardown, **plus** `transport.leave(matchId)` and `game.match = game.localOwner = null`. Its idempotence (`:399`) is exactly what a "server ended the match while you were in the Home dialog" race needs. |
| Refcounted pause (`:94-107`) | **local pause is gone in multiplayer** β one player cannot stop four others' clocks. `pauseLoop`/`resumeLoop` survive as *render-side* modal gating (Help, Home-confirm keep working), but must no longer gate `update`. Add a `pauseReasons` guard: in a networked match the sim never pauses; the overlay just goes up. Server-side pause (host-initiated, all-seat) is a v2 feature. |
| `game.spectateSpeed` / `speed:` at `:495` | ignored in multiplayer β the server owns the clock. |
**Four new lifecycle states** that do not exist today, each needing a screen or a banner:
1. **Lobby** β between map-select and `bootState`. New; ~150β250 LOC in `setup.js`.
2. **Waiting for seats / countdown** β trivial, lives in the lobby screen.
3. **Reconnect** β the transport drops; the client must show a banner and either resync from a full
snapshot or fall out to map-select. `game.netStatus` drives a CSS class, exactly like
`document.body.classList.toggle("paused", β¦)` at `boot.js:97`.
4. **Someone else left / was eliminated** β an in-match event with no analogue today (`state.over`
is binary). Needs a toast (`showGalaxyToast` already exists, `overlays.js`) and a seat-list
update.
### 2.4 What must not move
- **`main.js`'s side-effect imports** (`main.js:26-28`: `starmap.js`, `techChart.js`, `update.js`)
self-wire buttons and hotkeys at module-load time. Dropping one silently disables a feature. If
`starmap.js` is scoped out of v1 (Β§7), the *import must stay* with the module stubbed, or the
`M` key handler and the galaxy button go missing without an error.
- **`session.js`'s read-at-call-time discipline** (`session.js:8-10`). Every networked rewire depends
on being able to swap `game.state` under running consumers.
- **`bootState` as the single funnel.** Adding a second boot path for multiplayer is the exact
mistake ADR-0004 rejects at the transport layer, one level up.
---
## 3. "You are the player" assumptions
Three counted forms, **119 sites** in the shipping client:
| Form | Count | Distribution |
|---|---|---|
| **Comparisons** β `owner === "player"`, `!== "ai"` | **68** | `inputCommands.js` 16 Β· `hudSelection.js` 7 Β· `renderBuildings.js` 6 Β· `hud.js` 6 Β· `boot.js` 6 Β· `input.js` 5 Β· `renderEffects.js` 4 Β· `hudPanelSignature.js` 4 Β· `landingPicker.js` 4 Β· `minimap.js` 2 Β· `overlays.js` 2 Β· `renderShared.js` 2 Β· `starmap.js` 2 Β· `renderUnits.js` 1 Β· `observerPanel.js` 1 |
| **Owner arguments** β `prereqsMet(state,"player",β¦)`, `supplyUsed(state,"ai")` | **29** | `overlays.js` 12 Β· `hudSelection.js` 10 Β· `hud.js` 3 Β· `observer.js` 2 Β· `renderEffects.js` 1 Β· `techChart.js` 1 |
| **Property paths** β `state.players.player.resources` | **22** | `hudSelection.js` 11 Β· `hudPanelSignature.js` 7 Β· `hud.js` 2 Β· `observer.js` 1 Β· `overlays.js` 1 (+ `landingPicker.js:149`, optional-chained) |
Adding `tools/ailab.js` (9), `tools/duelCore.js` (1) and `competition.js` (2) brings the comparison
total to the **80** dossier 01's finding #5 reports.
The **owner-argument** form is the cheapest of the three and worth calling out: those 29 sites call
engine functions that are *already* owner-parameterized (`prereqsMet`, `countUnits`,
`hasCompletedBuilding`, `supplyUsed`/`supplyCap`, `powerCap`/`powerDraw`, `playerScore`,
`scoreBreakdown`, `commodityAvailable`, `committedDoctrine`, `recycleFrac`, `powerEfficiency`). The
engine is ready; only the caller hardcodes the seat.
Across all three forms the sites are **three different assertions** with three different costs.
### 3.1 "Is this entity mine?" β the large, easy class
The dominant pattern. Mechanical substitution: `"player"` β `state.localOwner`.
| File | Sites |
|---|---|
| `inputCommands.js` | `:58`, `:62` (fog gate on hit-test), `:99` (`selectedUnits`), `:131`, `:136` (box select), `:170`, `:178` (double-click type select), `:197`, `:210`, `:223`, `:232`, `:242`, `:247`, `:257`, `:264`, `:271` (right-click target dispatch) |
| `input.js` | `:317` (click select), `:383` (select-all-army), `:391` (idle worker), `:407` (idle producer), `:423` (CC cycle) |
| `hudSelection.js` | `:773`, `:789`, `:898`, `:1076`, `:1696`, `:1713`, `:1722` |
| `hudPanelSignature.js` | `:149`, `:234`, `:290`, `:312` |
| `hud.js` | `:47`, `:137`, `:194`, `:336`, `:352`, `:429` |
| `renderBuildings.js` | `:84` (enemy pip), `:120`, `:147`, `:165`, `:181`, `:233` (own-only overlays: rally lines, storage bars, jump staging, power grid) |
| `renderEffects.js` | `:413`, `:419`, `:563`, `:600`, `:637` |
| `renderUnits.js` | `:91` (enemy pip) |
| `renderShared.js` | `:254` (`hiddenByFog` β **the single fog predicate**) |
| `minimap.js` | `:94`, `:99` |
| `overlays.js` | `:68`, `:69`, `:71`, `:72`, `:73`, `:74`, `:78`, `:79`, `:80`, `:81` (tutorial objectives, via `countUnits(state,"player",β¦)` / `hasCompletedBuilding(state,"player",β¦)`) |
| `boot.js` | `:370` (camera to my CC), `:675` (event fog gate), `:737` (my supply block) |
| `starmap.js` | `:56`, `:58` |
| `landingPicker.js` | `:60`, `:61`, `:151`, `:160` |
| `techChart.js` | `:36` (`const OWNER = "player"` β **already one seam**) |
**110 of the 119 sites are this class. Cost: one line each, plus one place to set
`state.localOwner`.** `techChart.js:36` is the pattern to copy everywhere β name the seam once per
module, then read the name β and `hudPanelSignature.js:373` (`state.players[b.owner].upgrades`)
shows the property-path form done right.
**Do not sweep with `sed`.** `data.js:124` defines a commodity whose id is `"ai"` (AI cores), read as
`state.players.player.resources.ai` at `hudPanelSignature.js:272`. Site-by-site, or the economy
breaks silently.
### 3.2 "The enemy is `ai`" β the class that genuinely breaks at N seats
Nine sites. These do not have a mechanical fix; each needs a design answer.
| Site | Code | What breaks at 5 seats |
|---|---|---|
| `hud.js:314` | `const you = playerScore(state,"player"), foe = playerScore(state,"ai")` | The score chip shows **one** opponent. Needs a leaderboard, or "you vs. best rival". |
| `overlays.js:34` | `FACTIONS[st.players.player.faction]`, `FACTIONS[st.players.ai.faction]` | The faction chip is a two-sided "you vs them" line. |
| `overlays.js:376` | `winner === "player" ? playVictory() : playDefeat()` | Every non-winner hears defeat, including seats that placed 2nd of 5 β arguably fine, but it is a decision. |
| `overlays.js:392-394` | `winner === "player" ? "Victory β the enemy's last Command Center is destroyed." : "Defeat β your last Command Center was destroyed."` | "**the** enemy" is false with 4 opponents. Needs `winner === state.localOwner` plus a named-winner string. |
| `overlays.js:406-407` | `scoreBreakdown(opts.state,"player")`, `playerScore(opts.state,"ai")` | "Your score / Enemy score" β needs N rows. |
| `boot.js:684`, `:704`, `:712` | `if (ev.owner === "ai") triggerUnderAttack(ev.x, ev.y)` | **A live defect at N seats.** The header comment at `boot.js:669-671` states the assumption outright: *"An attackHit whose attacker is the AI necessarily means the target is the player's (only two sides exist)."* With 5 seats, seat 2 shelling seat 4 in your line of sight fires **your** under-attack alarm. Correct predicate: `ev.targetOwner === state.localOwner`. This requires the engine event to carry the target's owner β a change on the **engine** side (dossier 01's surface), flagged here because the client is where the bug is visible. |
| `observer.js:290-291` | `supplyUsed(state,"ai")`, `supplyCap(state,"ai")` | The observer's "what the neighbour has" panel is single-opponent by construction. |
| `observerPanel.js:89` | `owner === "player" ? watch.aName : watch.bName` | Two-entrant naming; out of v1 with competitions (Β§7). |
| `observer.js:116` | `state.map.bases.player \|\| state.map.bases.ai` | Free-camera home point; see Β§3.5. |
### 3.3 Colours
**Not a problem.** `#4fd1ff` and `#f87171` are owner colours **only** in `engine/state.js:179-180`,
as `ownerDefs` data. Every client occurrence of those hex strings is unrelated chrome:
- `style.css:13` `--accent: #4fd1ff`, `style.css:16` `--bad: #f87171` β UI theme tokens
- `renderEffects.js:111`, `:113` β per-unit-type weapon tracer colours (`lancer` beam,
`dreadnought` bolt), not team colours
- `renderEffects.js:58`, `:315`, `:326`, `:447`, `:511`, `:542`, `:584`, `:616`, `:680` β ghost
validity, waypoint lines, rally lines, escort rings
- `renderShared.js:195`, `renderBuildings.js:109`, `:154`, `:197`, `minimap.js:114`,
`renderUnits.js:126` β health-bar / power-tier / concern-badge palettes
- `landingPicker.js:149` β `dest.players?.player?.color || "#4fd1ff"` β **the one real coupling**:
a hardcoded fallback plus a hardcoded owner key. Odyssey-only, out of v1.
Every actual entity draw reads `state.players[owner].color`: `renderUnits.js:66`,
`renderBuildings.js:75`, `minimap.js:95`, `minimap.js:100`. **Adding seats 3β8 needs new entries in
`engine/state.js`'s `ownerDefs` and nothing in the renderers.** Two caveats: pick a palette that is
distinguishable at minimap-dot size (4β5 px) and colour-blind-safe, and decide whether colour is
per-seat (absolute) or per-viewer (you are always blue) β the latter is friendlier and costs one
remap at `bootState`, but breaks screenshots and spectating.
### 3.4 `state.fog` vs `state.fogAI`
The client reads `state.fog` at **9 sites** and never `state.fogAI`:
`inputCommands.js:58`, `:62`, `:69`; `renderEffects.js:466`; `boot.js:675`; `render.js:202`;
`minimap.js:36`, `:83`; `renderShared.js:254`; `renderNodes.js:26`.
`engine/state.js:232-233` makes `state.fog`/`state.fogAI` **aliases** into `state.fogs`. So the
obvious client fix β "point `state.fog` at *my* seat's fog and every reader keeps working" β is a
one-line change that makes all 9 sites correct at once.
> β οΈ **Do not do this before dossier 01's finding #6 is fixed.** `engine/gather.js:64` and
> `engine/scout.js:42` read `unit.owner === "player" ? state.fog : state.fogAI`. Rebinding the alias
> client-side is harmless *on a client that no longer simulates* β but the same client code path runs
> the loopback single-player server (ADR-0004), where it would silently resolve the wrong fog and
> desync. **Sequence: fix the engine, then rebind.**
Under the recommended fog-filtered replication (Β§4), the client is only ever *sent* one fog grid β
its own β so `state.fog` naturally is the local seat's fog and `state.fogAI` should not exist on a
client state at all. Assert that in a test.
### 3.5 `state.map.bases.player`
`boot.js:371` and `boot.js:471` open the camera at `state.map.bases.player`; `input.js:430` uses it
as the fallback for the CC-cycle key; `observer.js:116` falls back to `bases.player || bases.ai`.
`engine/map.js` builds `bases` as a 2-entry left/right mirror (dossier 01 finding #3). N-seat start
positions are an **engine/map** change; the client needs `state.map.bases[state.localOwner]` and
nothing more.
### 3.6 The "seat 3 of 5" test, summarized
| Layer | Verdict |
|---|---|
| Renderers, minimap, colours | **Already works.** Owner-generic reads throughout. |
| Selection, hit-testing, HUD panels, objectives | **Works after one substitution per site** (~71 sites). |
| Fog | **Works after one alias rebind**, once the engine's two `=== "player"` fog reads are fixed. |
| Under-attack alarm | **Broken** β fires on any `owner === "ai"` hit. Needs a target-owner field on the event. |
| Score chip, faction chip, victory copy, score breakdown | **Broken** β hardcoded two-sided copy. ~6 sites, all in `hud.js`/`overlays.js`, all cosmetic-but-visible. |
| Start positions | **Engine change** (`map.bases`), then one client substitution. |
---
## 4. Rendering under authoritative fog
### 4.1 How hard is per-client filtering, given `engine/fog.js`?
**Easy β the primitive already exists and is already the right shape.** `engine/fog.js` is 144 lines.
Fog is a coarse grid (`FOG_CELL_SIZE = 40`, `fog.js:23`) of two `Uint8Array`s per owner β
`visible` (recomputed from scratch every tick, `fog.js:125`) and `explored` (permanent). Every owner
has their own, keyed in `state.fogs` and recomputed by the same `updateFog(state, fog, owner)`
(`fog.js:124`). The whole filter predicate is one existing exported function:
```js
isVisibleAt(state.fogs[seat], e.x, e.y) // engine/fog.js:49 β two divides, one array index
```
Measured cost of filtering both entity collections for one seat: **0.24β0.69 ms** at 400β1,600 units
(Β§4.3). The server already pays `updateFog` per owner every tick regardless β filtering adds one
O(1) lookup per entity per seat, which is noise next to the `JSON.stringify` it *saves*.
The engine also documents the exact semantics we need: *"There's no 'remembered snapshot' of enemy
positions once they leave vision β they simply stop rendering"* (`fog.js:14-15`). So the shipped
game has **no last-known-position ghosts today**. Filtering therefore strips nothing the current
renderer draws for enemies.
### 4.2 What filtering would strip that the renderer legitimately needs
Six things. Five are already solved by how the codebase is built; one is a real decision.
1. **The static map β already free.** `serializeGame` does not include `state.map`; terrain, node
positions and base positions are regenerated from `seed` + `planetId` by `rehydratePlanet`
(`engine/persist.js`). The client is told the map config once at match start and builds the world
itself. **Nothing per-tick.**
2. **Resource nodes β must NOT be filtered by `visible`.** `renderNodes.js:26` and
`inputCommands.js:69` gate nodes on `isNodeDiscovered(state.fog, n)`, which reads **`explored`**,
not `visible` (`fog.js:66-67`) β deliberately: charted deposits are "map knowledge, not
battlefield intel" (`fog.js:10-13`), and only `hidden` caches need scouting. A filter that used
`visible` would make every deposit blink out the moment a worker walks away. **Filter nodes on
`explored`, not `visible`** β and note that `n.amount` changes as *anyone* mines: sending live
amounts for a node in enemy territory is an intel leak. Send `amount` only for nodes currently
`visible`; send last-seen `amount` otherwise.
3. **Craters and wrecks β survive filtering for free.** `engine/wreckage.js` and `engine/bomb.js`
turn into *resource nodes* with `crater: true` / `wreck: true`
(`engine/persist.js:549`, `:682-708`), drawn by `renderNodes.js:28` / `:114-150`. They are map
knowledge under rule 2, not entities. Pending (not-yet-matured) craters and wrecks are separate
top-level arrays (`persist.js:554`, `:562`) and are small β 0.0 KB and 0.8 KB measured.
**Send both unfiltered; they are terrain, and the deposit's existence is not secret intel.**
4. **The fog grid itself β send one, not two.** `serializeGame` ships `fog` **and** `fogAI`
(`persist.js:566-567`): **31 KB each on a 4Γ map**. Broadcasting both to every client literally
hands each player their opponent's vision map. The client needs exactly one grid β its own β and
only `explored` (which is what `drawFogBase`, `render.js:201-244`, paints from, plus
`isNodeDiscovered`). `visible` can be recomputed client-side from the entities the client can
see, or sent as a delta; `explored` is monotone and compresses to a run-length or a bitset
trivially (16,000 cells β 2 KB as a bitset, vs 31 KB as a JSON array of 0/1).
5. **Events β must be filtered, and the rule already exists.** `boot.js:675` reads
`if (ev.owner !== "player" && !isVisibleAt(state.fog, ev.x, ev.y)) continue;` β the client already
applies exactly the per-seat event filter the server needs. **Move that line to the server, per
seat, verbatim.** The under-attack trigger (`boot.js:684`, `:704`, `:712`) and the supply-block
beep (`:737`) then need the target-owner field from Β§3.2.
6. **Interpolation continuity β already safe, which is the surprise.** Under filtering an entity can
legitimately vanish between snapshots (it walked into fog) or appear mid-motion far from where it
was last seen. That is normally the first thing to break when a client stops holding the whole
world. Here all three cases are already handled, by code written for a different reason:
- **Appearing.** `lerpXY` (`renderShared.js:56-58`) returns the *live* entity when it has no
baseline β *"a unit spawned this tick"*. A newly-visible enemy snaps to its true position
instead of lerping from nowhere. β
- **Reappearing far away.** `TELEPORT_SQ = 60*60` (`renderShared.js:40`, applied at `:59-60`):
*"a one-tick move past this is a teleport, not motion β don't lerp it."* A unit re-sighted
across the map snaps rather than sliding. β
- **Vanishing.** `pruneFacing(state)` runs **every frame**, at the top of `drawFrame`
(`render.js:112`), dropping `prevPos` and `facing` entries for ids no longer in `state.units`
(`renderShared.js:69-76`). An entity that leaves the filtered view is cleaned up that frame;
if it comes back it has no baseline and takes the "appearing" path above. β
One cosmetic residue: a re-sighted enemy loses its remembered facing and re-derives from
`updateFacing`'s default (`renderShared.js:97`), so it may point "up" for a frame or two before
its next movement corrects it. A one-line fix if it ever reads badly (keep `facing` entries
longer than `prevPos`); not a blocker.
**`snapshotPositions` needs no change at all.** Called from `boot.js:501` immediately before each
`tick`, it becomes "immediately before applying each authoritative snapshot" β which is precisely
snapshot interpolation. The client already has the mechanism a networked renderer needs.
### 4.3 Measured payloads (this machine, `engine/persist.js` format)
`serializeGame` is the *save* format, not a wire format, so treat these as an upper bound with the
right shape. Node v22, same container as dossier 00.
| Scenario | Units | Full snapshot | `stringify` | Fog arrays | Fog-filtered per seat |
|---|---|---|---|---|---|
| Standard map, natural 20-min self-play match | 21 | 19.2 KB | 0.23 ms | 2 KB Γ 2 | 2.6 KB / 0.04 ms |
| Standard map, 200 v 200 | 406 | **96 KB** | 0.82 ms | 2 KB Γ 2 | 14 KB / 0.25 ms |
| 4Γ map, 200 v 200 | 406 | **154 KB** | 2.56 ms | **31 KB Γ 2** | 14 KB / 0.24 ms |
| 4Γ map, 400 v 400 | 806 | **236 KB** | 3.21 ms | 31 KB Γ 2 | 28 KB / 0.35 ms |
| 4Γ map, 800 v 800 | 1,606 | **404 KB** | 4.45 ms | 31 KB Γ 2 | 55 KB / 0.69 ms |
At 20 Hz with 4 seats:
- **Full broadcast**, 4Γ / 800v800: 404 KB Γ 20 Γ 4 = **32 MB/s egress**, and 4.45 ms Γ 4 =
**17.8 ms of a 50 ms tick** in `JSON.stringify` alone (the sim itself costs 22 ms p99 at this
size per dossier 00 β together they blow NFR-2).
- **Fog-filtered**, same scenario: 55 KB Γ 20 Γ 4 = 4.4 MB/s, 2.8 ms total. Still too much for
20 Hz snapshots without deltas, which is why Β§4.4's migration path exists β but it is the
difference between "needs delta encoding" and "architecturally impossible".
- **Realistic band** (4Γ map, 200v200, the near-supply-cap case dossier 00 calls normal): full
154 KB β 12 MB/s; filtered 14 KB β 1.1 MB/s.
Dossier 00's conclusion is confirmed from the other side. The sim runs ~500Γ real time; the server
spends its wall clock waiting. **Serialization is the cost centre, and it scales with what you send,
not with what you simulate.**
### 4.4 Recommendation
**Fog-filtered per client. From day one. Not as an optimization β as the architecture.**
Four reasons, in order of weight:
1. **ADR-0003 chose server authority specifically so fog could not be map-hacked** (its Context Β§1
and Β§2). Full-state broadcast reinstates the exact property lockstep was rejected for: every
client holds the whole map, and fog becomes a client-side rendering courtesy. It would make the
central architectural decision decorative.
2. **PRD G2 makes agents first-class players.** The MCP server (dossier 05) has to hand an agent a
legitimate, seat-scoped view. That filter has to exist regardless. Building it once and using it
for browsers too is strictly less work than building it for agents and a bypass for browsers.
3. **The numbers say full-broadcast does not fit** (Β§4.3), and the cost is in the *format*, not the
*filter*: `fog`+`fogAI` alone are 62 KB of the 154 KB at 4Γ, and both are per-seat secrets.
4. **Retrofitting is worse than starting there.** Every client module written against "I hold
everything" acquires a quiet dependency on data it should not have. `renderShared.js:253`'s
comment already names the failure mode: *"an inverted or dropped test doesn't crash or look wrong,
it quietly paints the enemy's army through the fog."*
**Trade-offs accepted, stated plainly:**
| Cost of filtering | Mitigation |
|---|---|
| Server does N filter passes per tick instead of 1 serialize | Measured 0.24β0.69 ms per seat; the sim it accompanies is 0.2β22 ms. Noise. |
| Interpolation must handle entities appearing/vanishing | **Already handled** β Β§4.2 item 6. Zero lines. |
| Spectator/replay wants full state | Give the **spectator** a synthetic all-seeing seat (`observerMode` already bypasses `hiddenByFog`, `renderShared.js:254`). One extra filter config, not an extra path. |
| Harder to debug ("why is the client missing X?") | A dev-only `--no-fog-filter` server flag, and a test that asserts a filtered snapshot contains no entity the seat cannot see. |
**Migration path** (each step independently shippable and testable):
1. **Send the map config, not the map.** Client rebuilds terrain/nodes from `{planetId, seed,
sizeMult, resourceMult}`. Removes the largest static payload before any filtering exists.
2. **Send one fog grid, not two**, and only `explored`, as a bitset. β60 KB/tick at 4Γ and closes
the vision leak immediately.
3. **Ship a full-snapshot loopback first** (ADR-0004). No filter, no socket: proves the
client-renders-a-given-state seam with the inherited tests still running.
4. **Insert the filter in the loopback.** `filterFor(state, seat)` β the same `View` shape. Single-
player now pays for and therefore *tests* the filter (ADR-0004's explicit intent). Assert
`filterFor(state,"player")` contains no entity outside `state.fogs.player`.
5. **Delta-encode.** Only entities whose fields changed since the client's last acked snapshot, plus
an id list for removals. This is where 55 KB becomes a few KB; do it after correctness.
6. **Then and only then, the socket.**
---
## 5. What is reusable verbatim
Client tree: **19,635 LOC** (17,903 JS + 1,732 CSS) across 38 files.
### 5.1 Zero change β 2,850 LOC (14.5%)
| Module | LOC | Why |
|---|---|---|
| `style.css` | 1,732 | No owner concept. Gains additive rules for the lobby and `netStatus`. |
| `data.js` | 242 | Pure display data (planet names, commodity table). One incidental `"ai"` β a **commodity** id (`:124`), not an owner. |
| `effects.js` | 183 | Particle/decal bookkeeping over `{x,y,type}`. Fed by replicated events instead of local ones β no signature change. |
| `sound.js` | 181 | Zero references to `state`, `player` or `ai`. |
| `renderNodes.js` | 171 | Owner-free; its one coupling is `state.fog` (`:26`), which the alias rebind (Β§3.4) handles. |
| `update.js` | 108 | Version-chip / auto-update. Untouched. |
| `camera.js` | 105 | Pure math. Zero references to `state`, `player`, `ai`. |
| `dom.js` | 68 | Element handles. |
| `version.js` | 60 | Version + save-impact display. |
### 5.2 Near-zero change β 5,942 LOC (30.3%)
A `localOwner` substitution, one predicate, or an added parameter. **No restructuring.**
| Module | LOC | Touch points |
|---|---|---|
| `renderBuildings.js` | 807 | 6 owner literals (`:84`, `:120`, `:147`, `:165`, `:181`, `:233`) |
| `renderUnits.js` | 699 | 1 (`:91`) |
| `renderEffects.js` | 683 | 5 (`:413`, `:419`, `:563`, `:600`, `:637`) |
| `overlays.js` | 585 | 10 objectives (`:68-81`) + 4 victory-copy sites (`:376`, `:392`, `:406`, `:407`) + 1 faction chip (`:34`) |
| `setup.js` | 462 | Option model and map cards verbatim; card click re-targets to the lobby. New lobby screen is *additive*. |
| `hud.js` | 452 | 6 owner literals + 2 `players.player.resources` reads + the 1v1 score chip (`:314`) |
| `hudPanelSignature.js` | 380 | 4 owner literals + 6 `players.player` reads. The signature *mechanism* β repaint only when the panel's meaningful inputs change β is exactly right for a snapshot-driven client and should be kept. |
| `techChart.js` | 366 | 1 line: `const OWNER = "player"` (`:36`) β `state.localOwner`. |
| `observer.js` | 304 | Repurposed as the spectator client (Β§7); 4 sites (`:116`, `:276`, `:290`, `:291`) |
| `render.js` | 277 | `state.fog` (`:202`) only β the interpolator needs nothing (Β§4.2 item 6) |
| `renderShared.js` | 255 | 1 predicate: `hiddenByFog` (`:253-255`) |
| `observerPanel.js` | 246 | 1 site (`:89`) |
| `main.js` | 168 | 2 `issue*` (`:149-150`) β transport sends. Everything else (canvas resize, DPR, panel folds, mute) verbatim. |
| `minimap.js` | 138 | 2 (`:94`, `:99`) |
| `session.js` | 120 | Additive fields only (Β§2.1) |
### 5.3 Real work β 3,662 LOC (18.6%)
| Module | LOC | Touch points | Nature |
|---|---|---|---|
| `hudSelection.js` | 1,998 | **67 distinct lines** (7 comparisons + 10 owner args + 11 property paths + 5 `issue*` + 33 mutators + 2 raw writes, one line double-counted) | Every panel's *layout* is verbatim; only the click handlers change from "call the engine" to "send a command", and the affordability reads re-point at the local seat. **3.4% of the file.** Mechanical but wide. |
| `boot.js` | 788 | The lifecycle itself | Β§2.3. The genuinely architectural file. |
| `input.js` | 588 | **18** (5 comparisons + 6 `issue*` + 6 selection writes + `map.bases.player` `:430`), plus `placeBuildingAt` (`:517-525`) going async | Gesture handling, camera, hotkeys, control groups all verbatim. **3% of the file.** |
| `inputCommands.js` | 288 | **35 distinct lines** (16 comparisons + 13 `issue*` + 5 selection writes + `state.fog` `:69`) β **12% of the file** | The densest coupling in the tree, and the most important to get right: it is the whole right-click dispatch. |
### 5.4 Out of multiplayer v1 β 6,779 LOC (34.5%)
`competition.js` 3,773 Β· `competitionLedger.js` 1,290 Β· `pairing.js` 518 Β· `competitionWorker.js` 355
Β· `starmap.js` 318 Β· `landingPicker.js` 211 Β· `playerFingerprint.js` 167 Β· `elo.js` 147. See Β§7.
### 5.5 Repurposed β 402 LOC (2.0%)
`saveload.js` 371 Β· `saveShape.js` 31. See Β§6.
### 5.6 The number that matters
Excluding the modules that do not ship in v1, the **in-scope client is 12,856 LOC**:
| Verdict | LOC | Share of in-scope |
|---|---|---|
| Verbatim | 2,850 | **22%** |
| Near-zero | 5,942 | **46%** |
| **Verbatim + near-zero** | **8,792** | **68%** |
| Real work | 3,662 | 28% |
| Repurposed | 402 | 3% |
**Roughly two-thirds of the shipping client is reusable as-is or with per-site substitutions.** That
is the strongest argument in this dossier for the port being a re-seaming job.
---
## 6. `saveload.js` / autosave / `localStorage` in multiplayer
### 6.1 What exists
`saveload.js` (371 LOC) runs two channels over `localStorage`:
- **Autosave** β a 12 s timer (`AUTOSAVE_INTERVAL_MS`, `:39`) plus `beforeunload` (`:370`),
writing `serializeGameString(game.state)` to `stellarfrontier.save.v1` (`:31`) or
`serializeGalaxyString(game.galaxy)` to `stellarfrontier.odyssey.v1` (`:32`), with a
rotated previous generation (`KEY + '.prev'`, `:33`, `writeGeneration` `:84-95`).
- **File export/import** β `saveToFile` (`:166`), `loadFromFile` (`:185`).
- **Resume** β `loadGame` (`:138`) / `loadOdyssey` (`:127`) feed `bootState`/`bootGalaxy`; the
setup screen's "Continue" button (`setup.js:347`, `:372`).
- **Failure surfacing** β `recordAutoSaveOutcome` (`:338`) toasts after 3 consecutive failures.
- `saveShape.js:29` `resumableMode` already refuses to checkpoint a state that is `over`, a
scenario, or a spectated match.
Other `localStorage` users: `update.js:23-24` (dismissed-update flag), `overlays.js:97-100`
(help-strip seen flag), `competitionLedger.js:1238-1290` (the ratings ledger).
### 6.2 Recommendation
**Client-side autosave of match state is deleted in multiplayer. Not disabled β deleted from the
multiplayer path.**
Reasoning:
1. **The client no longer holds the authoritative state.** `serializeGameString(game.state)` on a
fog-filtered view produces a save of a *partial world*. Loading it would resume a game with the
enemy's army missing. There is no correct thing for it to write.
2. **A single seat cannot resume a 4-seat match.** Resumption is a *match-level* operation: it needs
every seat's consent and every seat's presence. That makes it a server feature or nothing.
3. **"Continue" would be a lie.** The setup screen's Continue button restores *your* game. In
multiplayer there is no such thing.
**What replaces it:**
| Concern | Multiplayer answer |
|---|---|
| Crash / refresh mid-match | **Server-side match persistence + rejoin by `matchId`.** The server holds the state anyway; snapshot it to disk on a slow cadence (30β60 s) as a host-crash guard. The client keeps `{matchId, seat, token}` in `localStorage` β a few dozen bytes β and offers "Rejoin match" on the splash. This is the *right* use of `localStorage` here. |
| Replay / post-match analysis | **Server-side command log**, not a client save. Dossier 02 D7: a match replays from `{engineCommit, createGameStateOpts, dt, aiSeatConfigs, orderedCommandLog}`. That is a few KB for a whole match versus 400 KB for one snapshot, and it is the artefact the MCP/agent work (dossier 05) wants anyway. |
| Single-player | **Autosave stays, unchanged.** Under ADR-0004 the loopback server is in the tab and holds the full authoritative state. `saveload.js` re-points from `game.state` to `session.server.state` β a one-line change to `snapshot()` (`:68-76`) β and every existing test keeps meaning what it meant. |
**Keep, verbatim:** `update.js`'s dismissed-update flag, `overlays.js`'s help-strip flag,
`game.formation` / `game.collapsedSections` / `game.groups` if they are ever persisted. These are
per-viewer preferences, exactly what browser storage is for.
**Guard rails to add:**
- `saveShape.js:29` `resumableMode` gains one clause: return `null` when `game.match` is a networked
match. That single line disables the timer, the `beforeunload` write and the Continue button
together, because everything funnels through `snapshot()`.
- A test asserting that no networked match ever writes `stellarfrontier.save.v1`. The failure mode
here is silent (`saveload.js:42` swallows storage exceptions by design) so it must be asserted, not
observed.
---
## 7. Odyssey / galaxy / competition / observer β in or out of multiplayer v1
### Odyssey (open-world campaign) β **OUT**
**Client LOC:** `starmap.js` 318 + `landingPicker.js` 211 = **529**, plus large fractions of
`hudSelection.js` (lanes, colony policy, freight, spaceport, capital, electrify β 22 of the 38
mutator sites in Β§1.2), the galaxy branch of `boot.js`'s loop (`:502-524`), `startOdyssey`/
`bootGalaxy`/`performJump`/`initiateJump`/`surrenderOdyssey`/`focusActivePlanet`/`notifyColony`/
`celebrateMilestone` (`boot.js:259-388`, `:605-652`) β **180 LOC of `boot.js`**, and
`observer.js`'s galaxy spectating. Call it **~900 client LOC plus a third of `hudSelection.js`'s
behaviour**.
**Why out:**
1. **It is a different game shape.** Odyssey simulates *every world in the galaxy* each tick
(`boot.js:503` `stepGalaxy`), with the player controlling one and the rest running as background
colonies. Multiplayer would have to decide whose worlds tick, who sees which world, and what a
"jump" means when four players are on four different planets. That is a design project, not a port.
2. **It carries the engine's worst owner-literal concentration.** Dossier 01 finding #4:
35 of 53 engine owner literals live in `galaxy.js` (19), `scenarios.js` (4), `colonyPolicy.js` (3),
`sim.js`'s Odyssey-logistics block (4) and `diplomacy.js` (2) β *"in the Odyssey open-world and
scripted-mission layers, which a multiplayer skirmish never loads."* **Scoping Odyssey out of v1
removes two-thirds of the engine's N-player work.** This is the single highest-leverage scope
decision available.
3. **Credits are a galaxy-level singleton.** `game.galaxy.credits` (`hudSelection.js:291`, `:414`,
`:628`, `hud.js:257`, `starmap.js:87`) is one number for one player. There is no defined meaning
for it with 5 seats.
**Preserved through the loopback:** single-player Odyssey keeps working unchanged (ADR-0004), which
is the whole reason this scoping is cheap rather than a feature deletion.
**Keep the imports.** `main.js:26` side-effect-imports `starmap.js` for the M key and the galaxy
button. In a multiplayer build the module must still load (stubbed) or the hotkey silently disappears.
### Competitions / Elo β **OUT as shipped, but read `pairing.js` and `elo.js` first**
**Client LOC:** `competition.js` 3,773 + `competitionLedger.js` 1,290 + `pairing.js` 518 +
`competitionWorker.js` 355 + `playerFingerprint.js` 167 + `elo.js` 147 = **6,250 LOC β 32% of the
entire client tree, and the largest single module in it.**
**Why out:** the whole subsystem is a *local* ladder. `competitionLedger.js` stores ratings in
`localStorage` (`:1238-1290`); `competitionWorker.js` runs AI-vs-AI duels in a Web Worker;
`competition.js`'s Gauntlet plays fixtures against simulated opponents. Its founding constraint is
stated in `boot.js:148-153`: *"a human cannot play forty games"*, and *"NO side-swap, because the
human can only ever hold owner `player`"*. A real multiplayer ladder is server-side, cross-player and
authoritative β a different system that happens to share a rating formula.
**But two pieces are directly reusable and should not be rewritten:**
- **`elo.js` (147 LOC)** β a pure rating function. Move it server-side as-is for a real ladder.
- **`pairing.js` (518 LOC)** β round-robin/Swiss pairing and schedule generation. This is
matchmaking. Read it before writing a lobby queue.
- `playerFingerprint.js` (167 LOC) β `fingerprintPlayer(state, owner = "player")` (`:53`) is already
owner-parameterized. Useful later for agent behaviour analysis (dossier 05), not for v1.
### Observer Mode β **IN, repurposed as the spectator client**
**Client LOC:** `observer.js` 304 + `observerPanel.js` 246 = **550**.
**Why in β it is the cheapest feature in the port.** Observer Mode already is a
render-a-state-you-do-not-control client:
- It bypasses fog at the single predicate (`renderShared.js:254`'s `observerMode` argument,
threaded from `boot.js:547-548`), rather than mutating `state.fog` (`observer.js` header,
`renderShared.js:250-251`).
- It has its own camera (`game.observerCamera`, `session.js:103`) independent of `game.input`'s.
- It makes `input.js` refuse to issue orders β *"every mouse/wheel/key path already early-returns
into observer.js while `game.observerMode` is on"* (`boot.js:197-199`).
- It renders a **different state object** than the one being played (`observedState()`,
`boot.js:545`) β which is precisely "render a replicated view".
**The changes are small:** `observer.js:290-291`'s `supplyUsed(state,"ai")` becomes per-seat;
`observerPanel.js:89`'s two-entrant naming becomes an N-seat list; entry gating moves from
"Odyssey or `spectateMatch`" (`observer.js`) to "the server assigned me a spectator seat".
**A spectator is exactly the synthetic all-seeing seat Β§4.4 needs.** Server-side, a spectator gets a
filter config of "everything" (or, for competitive integrity, a delayed full view). The client-side
machinery for it already ships.
### Scenarios (Escort / Raider / Bounty) β **OUT of multiplayer v1, IN via loopback**
`boot.js:234-254` β three single-player scripted missions with their own objectives. Co-op scenarios
are an appealing v2 (`setupEscort` etc. already build a full state), but they are PvE content with
2-side scripting. Single-player keeps them unchanged through the loopback.
---
## 8. Offline single-player preservation
### 8.1 The loopback works for this client, and the client is unusually ready for it
ADR-0004 Option C requires that the client never touches `engine/` mutation directly and instead
talks to a session through a transport. From the client's side that is:
- **64 mutation call sites** (Β§1.1, Β§1.2) become `session.send(cmd)`. Every one already has an
explicit argument list; dossier 02's codec owns the idβobject resolution.
- **13 sim-ownership sites** (Β§1.5) move behind `session.createMatch(cfg)` / the transport's
match-start message.
- **`game.state` becomes the received view.** `session.js`'s read-at-call-time discipline
(`:8-10`) means every consumer already tolerates that object being swapped.
Because the loopback server runs **in the same tab**, single-player keeps: no network, no latency, no
service dependency, byte-identical determinism, and the full `serializeGame` save (Β§6).
### 8.2 Why this keeps the 2,519 tests meaningful
The inherited suite constructs a `State` and calls engine functions directly. Under ADR-0004:
- **`engine/` tests are untouched.** The engine's API does not change (dossier 02 D2: exactly one
signature change, `issueSetRally`).
- **Client tests that drive `boot.js`** (`test/boot.test.js` drives the real loop) keep working
because `bootState` keeps its shape (Β§2.3) and the loopback is synchronous.
- **The multiplayer machinery gets covered by every single-player test run** β the codec, the
ownership check and the per-seat filter all execute on the loopback path. That is ADR-0004's
entire thesis and it holds on the client side.
### 8.3 What in the client resists the loopback β five flags
1. **`input.js:517-525` `placeBuildingAt` needs a synchronous answer.** `issueBuild` returns the new
building's id and build mode exits only if it is truthy. Loopback is synchronous so this
*survives unchanged locally* β which is the trap: **it will pass every single-player test and
fail over a socket.** Either make the transport interface async-by-contract from day one (the
loopback resolving immediately), or the WebSocket path will be the first place anyone discovers
this. **Recommend: async-by-contract, and use ADR-0004's fault-injection mode (its Decision
section) to run the single-player suite with simulated latency in CI.** The same applies to
`hud.js:37`, `hudSelection.js:449/459/485/494` and `boot.js:287`.
2. **`boot.js:551` polls `game.state.over`.** Harmless over loopback, wrong over a socket (Β§2.3).
Make it a message from day one so the loopback exercises the message path.
3. **Refcounted pause gates `update()`** (`boot.js:500`). Correct for single-player, illegal for
multiplayer. The branch has to exist; ensure single-player takes the *same* code path with a
server-side pause command rather than a client-side `return`.
4. **`state.fog` alias rebinding is unsafe until the engine is fixed** β `engine/gather.js:64`,
`engine/scout.js:42` (Β§3.4, dossier 01 finding #6). Because the loopback server runs the same
engine in the same tab, a client-side rebind here does not merely mis-render, it **desyncs the
single-player sim**. Fix order matters.
5. **`main.js`'s side-effect imports** (`:26-28`) self-wire at module-load. A build that trims
modules for the multiplayer bundle must keep the import or explicitly re-wire; the failure is
silent (README already documents this).
### 8.4 What single-player retains, unchanged
Full-state rendering (its loopback server can hand it an unfiltered view, or the filtered one β it
should hand it the **filtered** one, so single-player tests the filter), autosave and Continue,
Odyssey, the three scenarios, competitions and the local ladder, Observer Mode, the file
export/import, and every hotkey. **Nothing in Β§7's "out of v1" list is deleted; it is only absent
from networked matches.**
---
## 9. Summary table
Verdicts: **verbatim** (no edits) Β· **light** (per-site substitution, no restructuring) Β·
**heavy** (restructuring) Β· **server-side** (moves out of the browser) Β·
**out-v1** (not in multiplayer v1; still shipped for single-player).
| Module | LOC | Verdict | Why |
|---|---|---|---|
| `style.css` | 1,732 | **verbatim** | additive lobby/net-status rules only |
| `camera.js` | 105 | **verbatim** | pure math, zero state coupling |
| `dom.js` | 68 | **verbatim** | element handles |
| `sound.js` | 181 | **verbatim** | zero owner/state references |
| `effects.js` | 183 | **verbatim** | `{x,y,type}` bookkeeping; fed by replicated events |
| `data.js` | 242 | **verbatim** | pure display data (`:124`'s `"ai"` is a commodity) |
| `version.js` | 60 | **verbatim** | version/save-impact chip |
| `update.js` | 108 | **verbatim** | auto-update chip; keeps its `localStorage` flag |
| `renderNodes.js` | 171 | **verbatim** | owner-free; `state.fog` (`:26`) via the alias rebind |
| `renderBuildings.js` | 807 | **light** | 6 literals: `:84`, `:120`, `:147`, `:165`, `:181`, `:233` |
| `renderUnits.js` | 699 | **light** | 1 literal: `:91`. Colour already generic (`:66`) |
| `renderEffects.js` | 683 | **light** | 5 literals: `:413`, `:419`, `:563`, `:600`, `:637` |
| `overlays.js` | 585 | **light** | objectives `:68-81`; victory copy `:376`, `:392`, `:406-407`; faction chip `:34` |
| `setup.js` | 462 | **light** + additive | option model verbatim; new `renderLobby()` (~150β250 LOC) |
| `hud.js` | 452 | **light** | 6 literals + 2 `players.player` reads + 1v1 score chip `:314` |
| `hudPanelSignature.js` | 380 | **light** | 4 literals + 6 `players.player` reads; the signature idea is *right* for snapshots |
| `techChart.js` | 366 | **light** | one line: `:36` `const OWNER = "player"` |
| `observer.js` | 304 | **light** | repurposed as spectator; `:116`, `:276`, `:290`, `:291` |
| `render.js` | 277 | **light** | `state.fog` `:202`; interpolator already fog-safe (Β§4.2.6) |
| `renderShared.js` | 255 | **light** | one predicate: `hiddenByFog` `:253-255` |
| `observerPanel.js` | 246 | **light** | `:89` two-entrant naming β N seats |
| `main.js` | 168 | **light** | 2 `issue*` `:149-150`; side-effect imports `:26-28` must survive |
| `minimap.js` | 138 | **light** | 2 literals `:94`, `:99`; colours already generic `:95`, `:100` |
| `session.js` | 120 | **light** | additive fields: `transport`, `match`, `localOwner`, `serverTick`, `lobby`, `netStatus` |
| `hudSelection.js` | 1,998 | **heavy** (wide, shallow) | 59 touch points = 3% of the file; layout verbatim, handlers become sends; **`:1045`, `:1722` need new engine commands** |
| `boot.js` | 788 | **heavy** | the lifecycle: loop split, match-end message, pause semantics, lobby entry |
| `input.js` | 588 | **heavy** | 17 touch points + `placeBuildingAt` `:517-525` goes async |
| `inputCommands.js` | 288 | **heavy** | 37 touch points = 13% of the file; the right-click dispatch |
| `saveload.js` | 371 | **server-side** | match persistence + rejoin move to the server; single-player keeps it (Β§6) |
| `saveShape.js` | 31 | **light** | one clause: refuse to checkpoint a networked match |
| `competition.js` | 3,773 | **out-v1** | local ladder vs simulated opponents |
| `competitionLedger.js` | 1,290 | **out-v1** | `localStorage` ratings store |
| `pairing.js` | 518 | **out-v1** | **read first** β this is matchmaking |
| `competitionWorker.js` | 355 | **out-v1** | Web Worker duel runner |
| `starmap.js` | 318 | **out-v1** | Odyssey galaxy map; keep the `main.js` import |
| `landingPicker.js` | 211 | **out-v1** | Odyssey jump landing site |
| `playerFingerprint.js` | 167 | **out-v1** | already owner-generic (`:53`); useful for agent analysis later |
| `elo.js` | 147 | **out-v1** | **reuse server-side** β pure rating function |
| **Total** | **19,635** | | |
**Roll-up (in-scope for v1 = 12,856 LOC):** verbatim 2,850 (22%) Β· light 5,942 (46%) Β·
heavy 3,662 (28%) Β· server-side/repurposed 402 (3%). **Verbatim + light = 68%.**
---
## 10. Ordered recommendations
1. **Close the two raw entity writes before the codec ships.** `hudSelection.js:1045` routes to the
existing `issueSetHomeBase([unit], null)` (`engine/commands.js:281`) β no engine change at all.
`hudSelection.js:1722` needs a new `issueSetElectrified(state, buildingIds, on)`, with
`engine/aiIndustry.js:172` routed through it too so the field has exactly one writer. Smallest
change in this document, largest correctness payoff: these are the only client mutations dossier
02's codec cannot see.
2. **Fix `engine/gather.js:64` and `engine/scout.js:42` before anything rebinds `state.fog`.**
Dossier 01 finding #6. Under the loopback this is a desync, not a render bug.
3. **Introduce `state.localOwner` (or `game.localOwner`) and sweep the 71 "is this mine" literals
site-by-site.** Never with a blind `sed`: `data.js:124` defines a commodity called `"ai"`.
4. **Scope Odyssey and competitions out of multiplayer v1.** Removes 6,779 client LOC and β per
dossier 01 finding #4 β two-thirds of the engine's owner-literal work. Both keep working in
single-player via the loopback.
5. **Make the transport interface asynchronous by contract from day one**, and run the single-player
suite under ADR-0004's fault-injection latency in CI. Otherwise `input.js:521` and the four other
return-value sites pass every test and break over the socket.
6. **Replace the `game.state.over` poll (`boot.js:551`) with a `match-end` message immediately**, so
the loopback exercises the message path from the first commit.
7. **Ship fog-filtered replication from day one** (Β§4.4), following the six-step migration path.
Send the map config not the map, and one `explored` bitset not two JSON fog arrays.
8. **Keep `bootState` as the single boot funnel and `session.js`'s read-at-call-time discipline.**
They are why the rest of this is cheap.
|