SpaceCities / docs /analysis /02-command-wire-protocol.md
Almaatla's picture
Claude Opus 5
docs: delivery plan, engine/command dossiers, and the ADRs they ground
ef7537c unverified
|
Raw History Blame Contribute Delete
72.9 kB

02 β€” Command & Wire Protocol

Status: analysis complete, decisive. Primary input to docs/adr/ (proposed ADR-0002 "Wire commands are id-based; engine/commands.js is wrapped, not rewritten").

Sources read: /home/user/alma92350/spaceexploration-rts (read-only clone). All file.js:line citations below refer to that tree unless prefixed with SpaceCities/.


0. Decisions up front

# Decision Where it lands
D1 Wrap, do not rewrite. engine/commands.js keeps its object-ref signatures. A new net/commandCodec.js owns id→object resolution, ownership, fog and rate limits. §4
D2 Exactly one signature change to the engine: issueSetRally(building, …) β†’ issueSetRally(state, buildingId, …). 3 call sites. Everything else is untouched. Β§4.4
D3 The wire envelope is versioned, id-based, owner-stamped by the server (never by the client), and tick-scheduled. The client's ownerId and tick fields are advisory/telemetry only. Β§3.1
D4 Selection id arrays are ORDER-SIGNIFICANT and must never be sorted. ids[0] is the formation leader (engine/commands.js:145, :195) and issueEscort derives ring slots from array index (engine/commands.js:363). Β§3.3
D5 Application order is (applyTick, ownerIndex, clientSeq) β€” ownerIndex = state.owners.indexOf(owner), engine/state.js:227. Commands apply immediately before tick(state, dt), never inside it. Β§5
D6 state.selection is UI-only. It moves to the client session. The field stays on State as a permanently-empty array (zero churn β€” removeEntity writes it, engine/state.js:347), guarded by a test that no server module reads it. Β§6
D7 A match is replayable from {engineCommit, createGameStateOpts, dt, aiSeatConfigs, orderedCommandLog} β€” provided B1 below is fixed. Β§7
D8 Five engine defects block multiplayer and must be fixed before the codec ships. The worst is a module-global entity-id counter that makes two concurrent matches in one Node process non-replayable. Β§8

Scope correction (important). The brief states engine/commands.js is the entire player-intent surface. It is the entire unit-order surface. It is not the entire player-intent surface: hudSelection.js:20-35 imports and calls ~20 further cost-bearing engine mutators directly (production, research, market, diplomacy, colony, galaxy). See Β§1.6. The wire protocol must cover both, and the second group is where the money is.


1. Signature audit β€” every export of engine/commands.js

22 exports, engine/commands.js:231–:533.

Legend for Params: OBJ = live object reference (unserialisable), ID = string id, SC = scalar/plain-JSON, STATE = the whole State.

1.1 The table

# Function Line Exact signature Param kinds Owner check? Afford check? Mutates players[x].resources? State written
1 issueMove 231 (units, x, y, queue = false, formation) units:OBJ[], x,y,queue:SC, formation:SC NO n/a no u.order, u.orderQueue, u.hold, u.squadLeader, leader.squadFollowers, u.facing, order.speedCap
2 issueGather 237 (units, nodeId, queue = false) units:OBJ[], nodeId:ID, queue:SC NO n/a no u.order={type:"gather",nodeId} (filtered by canGatherType)
3 issueServiceBuilding 244 (units, buildingId, queue = false) units:OBJ[], buildingId:ID NO (neither unit nor building) n/a no u.order={type:"service",buildingId,phase:"plan",manual:true}
4 issueFerryFreighter 255 (units, freighterId, queue = false) units:OBJ[], freighterId:ID NO n/a no u.order={type:"ferry",…}
5 issueRepair 267 (units, targetId, queue = false) units:OBJ[], targetId:ID NO n/a no u.order={type:"repair",targetId,phase:"toSite",manual:true}
6 issueSetHomeBase 281 (units, ccId) units:OBJ[], ccId:ID NO n/a no u.homeCC β€” ccId never validated as existing, as a building, as a CC, or as owned
7 issueSetAILogistics 295 (units, on, state) units:OBJ[], on:SC, state:STATE partial β€” reads state.players[u.owner].upgrades (:298), so it is correctly owner-scoped for the tech gate, but does not check the caller owns the unit n/a no (upkeep is charged later, haul.js payAIUpkeep) u.aiLogistics, u.cargo
8 issueSetCollectPoint 310 (units, on) units:OBJ[], on:SC NO n/a no u.collectPoint, u.anchor
9 issueSetLogiPriority 330 (state, buildingId, priority) state:STATE, buildingId:ID, priority:SC NO n/a no b.logiPriority on any building in the world
10 issueAttack 343 (units, targetId, queue = false) units:OBJ[], targetId:ID NO β€” and no hostility check either n/a no u.order={type:"attack",targetId} (filtered to def.attack || role==="support")
11 issueAttackMove 350 (units, x, y, queue = false, formation) as issueMove NO n/a no as issueMove, order type attack-move
12 issueEscort 361 (units, targetId, queue = false) units:OBJ[], targetId:ID NO n/a no u.order={type:"escort",targetId,slot:i,slots:n} β€” no role filter at all
13 issueHoldFormation 375 (units, shape = "grid", leaderPos = "front") units:OBJ[], shape,leaderPos:SC NO n/a no hold-formation orders + u.hold for role==="combat"; anchor is the live centroid of units
14 issueBuild 390 (state, workerId, buildingType, x, y) state:STATE, workerId:ID, buildingType,x,y:SC NO β€” derives player from worker.owner (:393) YES canAfford :404 YES β€” payCost :407 mints a constructing building into state.buildings, sets worker.order; returns the new building id
15 issueAssistBuild 420 (units, buildingId, buildingType, queue = false) units:OBJ[], buildingId,buildingType:ID/SC NO n/a no u.order={type:"build",buildingId}; buildingType is trusted from the caller and only used to resolve the eligibility category (:421)
16 issueStop 434 (units) units:OBJ[] NO n/a no clears order, orderQueue, hold, recycling, squadLeader
17 issueRecycle 444 (entities) entities:OBJ[] (mixed Unit|Building) NO n/a indirectly YES β€” beginRecycle starts a timer that recycle.js:136-138 later banks into state.players[entity.owner].resources, and removeEntitys the entity e.recycling, e.order, e.hold
18 issueCancelRecycle 454 (entities) entities:OBJ[] NO n/a no clears e.recycling
19 issueHold 462 (units) units:OBJ[] NO n/a no u.hold = true, clears orders (combat role only)
20 issuePatrol 480 (units, points) units:OBJ[], points:SC[] NO n/a no a looping attack-move … patrol:true chain; points length is unbounded
21 issueScout 509 (units) units:OBJ[] NO n/a no u.order={type:"scout",speedCap?} (scout role only)
22 issueSetRally 531 (building, x, y, nodeId = null) building:OBJ, x,y,nodeId:SC NO n/a no building.rally = {x,y,nodeId} β€” zero validation of anything

1.2 The central problem, confirmed

20 of 22 take live object references. Only issueBuild (:390) and issueSetLogiPriority (:330) are fully id-based; issueSetRally (:531) is the extreme case, taking a bare Building object with no state at all.

The object refs are not incidental β€” they are structural. dispatchFormation (:144) stores references: leader.squadFollowers = newFollowers (:195) and dispatch(units[i], {type:"follow-leader", leader, …}) (:204-207) puts a live Unit object inside an order. setSquadLeader (:46) maintains a bidirectional object graph. That graph is explicitly non-serialisable and persist.js already deals with it by dropping it β€” see the comment at engine/commands.js:43-45: "Transient, session-only state (never persisted β€” see persist.js's serPlanet, which strips both fields and drops a live follow-leader order entirely rather than trying to serialize the object reference it carries)."

Consequence for netcode: the object graph is fine inside the authoritative server sim (it never crosses a wire). What crosses the wire is only the intent. So the id→object boundary belongs in an adapter, not in the engine. This is the single most important architectural fact in this document, and it is what makes D1 correct.

1.3 Hidden inputs that are not parameters

Three commands read state the wire schema must therefore also carry or recompute:

  • issueHoldFormation (:377-379) computes the anchor from the live centroid of the passed units. The anchor is therefore a function of when the command applies. Two identical commands applied at different ticks produce different worlds. Scheduling must be authoritative and logged (Β§5).
  • issueMove/issueAttackMove cap group speed from UNITS[u.type].speed of the live set (groupSpeedCap, :66-69) and re-derive leader.squadFollowers.
  • issueScout (:514-518) reads and prunes u.squadFollowers by hp > 0.

1.4 The owner === "player" gate β€” a hard multiplayer blocker

engine/commands.js:155:

if (leader.owner !== "player") {
  const spots = formationSlots(units, x, y, formation);
  units.forEach((u, i) => dispatch(u, makeLeaderOrder(spots[i]), queue));
  return;
}

The entire leader/follower squad mechanic β€” the thing that makes formations formations rather than a one-shot grid spread β€” is gated on the literal owner id "player". In a 4-player match with owners p1..p4, no seat gets formations. See Β§8/B2.

1.5 Silent-skip is the house style

Every role/capability filter in this file silently skips ineligible units rather than failing the call (canGatherType :238, canLogisticsType :246, role === "combat" :464, canBuildCategory :422). This is deliberate and documented (:293-294, :441-443). The codec must preserve it: a mixed selection must not be rejected wholesale because one unit is ineligible. Only ownership violations are hard rejects (Β§2.6).

1.6 The second intent surface β€” engine/commands.js is not the whole story

hudSelection.js:20-35 imports these directly, and they are all reachable from a button click:

Module Exports the HUD calls Cost-bearing?
engine/production.js queueProduction :118, cancelProduction :164, researchUpgrade :185 yes β€” payCost at :152, :205; refund at :173
engine/techtree.js researchTech :190, cancelResearch :224 yes
engine/market.js sell :164, buy :208 yes
engine/diplomacy.js offerTribute :248, offerGift :271, fulfillRequest :290 yes
engine/colony.js deployColonyShip :31, packCommandCenter :70 yes (PACK_COST, :86)
engine/colonyPolicy.js setColonyPolicy :82 no
engine/bomb.js lightFuse :249 no (destructive)
engine/galaxy.js upgradeSpaceport, loadFreighter, unloadFreighter, createLane, deleteLane, assignShipToLane, upgradeToCapital, jumpVessel yes

All three of queueProduction, cancelProduction, researchUpgrade derive the paying player from building.owner, not from a caller-supplied owner:

// engine/production.js:140
const player = state.players[building.owner];
if (!canAfford(player.resources, cost)) return false;

So they have exactly the same exposure class as issueBuild (Β§2.2). The wire protocol below is designed as an open union so these fold in as additional command types with the same envelope, resolver and ownership rule β€” see Β§3.6. Recommendation: ship the unit-order commands (Β§3.4) in phase 1 and the economy commands (Β§3.6) in phase 2, both through the same codec.


2. Ownership & validation gaps β€” the anti-cheat surface

Baseline: in single-player none of this matters, because the only callers are inputCommands.js (which pre-filters to owner === "player", e.g. inputCommands.js:99, :206, :211) and the AI (which passes its own units). Ownership enforcement lives entirely in the UI. Expose these over a socket naively and every one of them becomes a cheat.

Ranked by severity.

2.1 CATASTROPHIC β€” destroy or disable another player's army

Command Attack
issueRecycle(entities) :444 Send the enemy's building/unit ids. canRecycle (recycle.js:80) only refuses a Command Center, a constructing building, or an already-recycling entity. Everything else starts a timer that ends in removeEntity (recycle.js:149, :163). You can dismantle an opponent's entire base.
issueStop(units) :434 Send every enemy unit id every tick. Clears order, orderQueue, hold, and recycling. The opposing army is permanently frozen β€” it can still auto-defend (combat.js re-acquires), but never moves, gathers, or builds again.
issueAttack(units, targetId) :343 Two attacks in one. (a) Send your units at a friendly/allied target: combat.js:46 reads unit.order.targetId with no owner filter, and performAttack (combat.js:85) is reached without one β€” explicit orders are friendly-fire capable, unlike auto-acquisition which does filter (combat.js:153, :243, :386, :411). (b) Send the enemy's unit ids at the enemy's own buildings and they self-destruct.
issueHold(units) :462 Freeze the enemy's combat units in place (u.hold = true), then walk past them: combat.js:83 refuses to chase while unit.hold.

recycle.js:87-89 carries a comment asserting the guard exists:

"Start recycling entity in place. Pure state mutation β€” engine/commands.js's issueRecycle checks ownership/canRecycle and handles the unit-order-dispatch side…"

issueRecycle (commands.js:444-450) checks canRecycle and nothing else. The comment is wrong. Fix the comment as part of the codec work so the next reader is not misled into trusting a check that does not exist.

2.2 SEVERE β€” spend another player's resources

Command Attack
issueBuild(state, workerId, …) :390 const player = state.players[worker.owner] :393, then payCost(player.resources, def.cost) :407. Name an enemy worker id and you drain their treasury and hijack their worker's order (:411). You do not gain the building β€” but you can bankrupt them and pin their workers to construction sites at will. Placement, prereqs and affordability are all validated against the victim, so a well-chosen spam of expensive buildings is a total economic denial.
queueProduction(state, buildingId, …) production.js:118 Same shape: state.players[building.owner] :140, payCost :152. Fill the enemy's queues, drain their bank, and consume their supply cap (:147).
cancelProduction(state, buildingId, i) production.js:164 Delete an arbitrary index out of any building's queue (:169). Refunds to the owner, so it is pure griefing: cancel the enemy's army as fast as they queue it.
researchUpgrade / researchTech Same derivation; burn the victim's bank on a doctrine they did not choose, and β€” because of the doctrine lock (production.js:194-195) β€” permanently deny them the other doctrine. This is the most damaging economic attack in the set.

2.3 MODERATE β€” sabotage, waste, and free labour

Command Attack
issueSetLogiPriority(state, buildingId, priority) :330 No owner check anywhere. Set every enemy factory to "low" and their logistics chain starves (haul.js priorityWeight).
issueSetRally(building, x, y, nodeId) :531 Object-ref, zero validation. Once the codec resolves an id, an unguarded path re-points every enemy production building's rally into a corner of the map β€” or onto your own guns.
issueSetHomeBase(units, ccId) :281 ccId is never validated (not existence, not kind, not owner). Point the enemy's workers at your CC and their whole zoneFirst job search (gather.js) goes wrong.
issueRepair(units, targetId) :267 No owner check on either side. Order your workers to repair the enemy's CC. repair.js:161/:172 gate the passive Mender scan on owner, but updateRepairJob runs off the explicit order. Mostly self-harm β€” but in a team game it is a way to launder resources/labour to a nominal opponent.
issueAssistBuild(units, buildingId, buildingType) :420 Two holes. (a) buildingId is unvalidated β€” send workers to accelerate an enemy site. (b) buildingType is trusted from the caller and is the only thing that resolves the eligibility category (:421). A client that lies about the type walks a combat unit onto a construction site the engine would otherwise refuse. The codec must read the type from the resolved building, never from the wire.
issueSetAILogistics :295 / issueSetCollectPoint :310 Flip the enemy's freighters into/out of AI-logistics mode. The tech gate is correctly scoped to u.owner (:298) so you cannot grant them a mode they have not researched β€” but you can force one on, which burns their AI Cores (haul.js payAIUpkeep), or force one off mid-haul.
issueServiceBuilding :244 / issueFerryFreighter :255 Same class: unvalidated target ids, cross-owner assignment.

2.4 MAPHACK β€” fog is enforced only in the UI

inputCommands.js gates target picking on fog: entityAt skips non-player entities that fail isVisibleAt(state.fog, …) (inputCommands.js:58, :62) and nodeAt requires isNodeDiscovered (:69). No engine function checks fog. A client that ignores its own renderer can:

  • issueAttack a unit it has never seen (targeted alpha-strikes into fog),
  • issueGather an undiscovered node β€” instant map knowledge of every deposit,
  • issueBuild anywhere on the map: canPlaceBuilding (colliders.js:26-47) checks bounds, building overlap, node overlap and terrain β€” never fog and never proximity to your own territory. Wall in an enemy's expansion on turn one.

Fog enforcement is therefore a new server-side rule the codec must add; it does not exist anywhere in the engine today.

2.5 DoS / resource-exhaustion

  • issuePatrol(units, points) :480-493 β€” points is unbounded and every point is pushed onto every unit's orderQueue. |units| Γ— |points| allocations with no cap. 400 units Γ— 100k points is a server OOM.
  • Selection size is unbounded everywhere. dispatchFormation β†’ formationSlots β†’ clusterUnits (formation.js:108) is superlinear in group size.
  • Command rate is unbounded. issueStop on 400 units at 20 Hz is cheap for the attacker and expensive for the server.

2.6 The rule that fixes 2.1–2.3 in one place

Every entity id on the wire resolves through the codec, and every resolution is scoped to the submitting owner. A unit/building the submitter does not own is a hard reject of the whole command. A unit/building that has ceased to exist is a silent drop of that one id (it is a legitimate race between issue and apply, not a lie).

That distinction matters: rejecting on "not found" would make the protocol fail constantly under normal packet latency, while dropping on "not yours" would let an attacker probe the world for free.

Targets (the object of a command, not the subject) get a different rule β€” see Β§3.5.


3. Wire schema

3.1 The envelope

One JSON object per command. Sent client→server over the WebSocket; the same shape, once stamped, is what goes in the replay log.

{
  "v": 1,                     // PROTOCOL_VERSION β€” reject on mismatch, never coerce
  "seq": 417,                 // per-client monotonic counter, starts at 1
  "tick": 1183,               // ADVISORY: the tick the client believed it was on
  "cmd": { "t": "move", "ids": ["u12","u7"], "x": 900, "y": 412, "q": false,
           "f": { "s": "wedge", "l": "front", "hx": 1, "hy": 0 } }
}

Server-side, after admission, it becomes a log record:

{
  "v": 1,
  "seq": 417,
  "owner": "p2",              // AUTHORITATIVE β€” from the socket's session, never the client
  "applyTick": 1186,          // AUTHORITATIVE β€” stamped by the server at admission
  "cmd": { … },               // verbatim, post-validation
  "result": { "ok": true, "buildingId": "b41" }   // echo for build-like commands
}

Deliberate design points.

  • owner is never on the clientβ†’server wire. It is stamped from the authenticated session. A field a client can set is a field a client will lie about. This is the single change that neutralises Β§2.1 and Β§2.2 by construction β€” an id-based protocol whose owner is client-supplied is no safer than passing object refs.
  • tick is advisory. The server stamps applyTick itself (Β§5.2). The client value is kept only for latency telemetry and for detecting a client running ahead.
  • seq is the tie-break and the replay/duplicate guard. (owner, seq) must be unique; a repeat is dropped idempotently.
  • Short keys (t, q, f, s, l, hx) because these are the highest-rate messages in the protocol. Everything else in the game (chat, lobby, state snapshots) can afford long keys.

3.2 Batches

One right-click already fans out into several issue* calls β€” inputCommands.js:90-96 splits a selection into combatants (issueAttackMove) and everyone else (issueMove), and commandAt (:194-285) picks one of eight verbs. Decision: the client resolves the gesture into primitive commands, and ships them as an atomic batch.

{ "v": 1, "seq": 418, "cmd": { "t": "batch", "c": [ {…}, {…} ] } }

A batch applies at one applyTick, in array order, all-or-nothing on validation (if any member is rejected, the whole batch is rejected β€” the client's disambiguation was built on a state it did not actually have). Max 16 members.

Rationale for client-side gesture resolution: commandAt is 92 lines of UI-policy (inputCommands.js:15-17 calls this out explicitly), it depends on camera/pick-radius/touch-mode, and an MCP agent has no gesture at all β€” it wants to say attack, not "right-click at (900,412)". Putting the verb on the wire also makes the log human-readable, which matters enormously for replay debugging. The cost is that a lying client can pick a verb the UI would not have offered β€” which is exactly what Β§3.5's server-side re-validation is for.

3.3 How selections are expressed

/** Ordered, de-duplicated entity ids. ORDER IS LOAD-BEARING. */
type Ids = string[];   // 1..400

ids[0] is the formation leader: dispatchFormation takes const leader = units[0] (commands.js:145), assigns leader.squadFollowers = units.slice(1) (:195), and rankSlotsByRange passes spots[0] through untouched because "the leader is a documented player choice … never re-picked by a stat" (:87-88). issueEscort likewise derives slot: i, slots: n from array position (:363).

Therefore the codec must not sort, canonicalise or re-order ids. It de-duplicates preserving first occurrence, and drops dead ids in place. This is also why the client's state.selection order is preserved through applyBoxSelection's promote-to-front behaviour (inputCommands.js:152) β€” that ordering is game input and must be logged verbatim.

3.4 Command types β€” phase 1 (unit orders)

/** engine/formation.js:59-60 β€” the ONLY legal values. */
type Shape  = "grid" | "line" | "wedge" | "circle";
type LeadPos = "front" | "back" | "center";

/** Rides on move / attack-move. Maps to engine/commands.js's `formation` opts bag. */
interface WireFormation {
  s?: Shape;      // shape;     default "grid"
  l?: LeadPos;    // leaderPos; default "front"
  hx?: number;    // headingX β€” the right-click-DRAG vector; stamped as unit.facing (commands.js:218)
  hy?: number;    // headingY
}
// NOTE: originX/originY are NOT on the wire. issueHoldFormation derives them
// server-side from the live centroid (commands.js:377-380).

type WireCommand =
  // ---- movement -----------------------------------------------------------
  | { t: "move";        ids: Ids; x: number; y: number; q?: boolean; f?: WireFormation }
  | { t: "attackMove";  ids: Ids; x: number; y: number; q?: boolean; f?: WireFormation }
  | { t: "holdFormation"; ids: Ids; s?: Shape; l?: LeadPos }
  | { t: "patrol";      ids: Ids; pts: Array<{ x: number; y: number }> }   // 1..32
  | { t: "stop";        ids: Ids }
  | { t: "hold";        ids: Ids }
  | { t: "scout";       ids: Ids }

  // ---- targeted at another entity -----------------------------------------
  | { t: "attack";      ids: Ids; target: string; q?: boolean }
  | { t: "escort";      ids: Ids; target: string; q?: boolean }
  | { t: "repair";      ids: Ids; target: string; q?: boolean }
  | { t: "gather";      ids: Ids; node:   string; q?: boolean }
  | { t: "service";     ids: Ids; target: string; q?: boolean }   // building
  | { t: "ferry";       ids: Ids; target: string; q?: boolean }   // own freighter
  | { t: "setHomeBase"; ids: Ids; target: string }                // own command center
  | { t: "assistBuild"; ids: Ids; target: string; q?: boolean }   // NOTE: no buildingType β€” server reads it

  // ---- construction / teardown --------------------------------------------
  | { t: "build";       worker: string; b: string; x: number; y: number }
  | { t: "recycle";       ids: Ids }   // units AND buildings
  | { t: "cancelRecycle"; ids: Ids }

  // ---- toggles / properties ------------------------------------------------
  | { t: "setAILogistics";  ids: Ids; on: boolean }
  | { t: "setCollectPoint"; ids: Ids; on: boolean }
  | { t: "setLogiPriority"; building: string; p: "high" | "normal" | "low" }
  | { t: "setRally";        building: string; x: number; y: number; node?: string | null }

  // ---- envelope-level ------------------------------------------------------
  | { t: "batch"; c: WireCommand[] };   // 1..16, no nesting

Mapping notes.

  • q is queue β€” the Ctrl-modifier. Pure boolean, rides on every command whose engine signature has a queue parameter. It is not meaningful for stop, hold, scout, holdFormation, patrol, recycle or the toggles, and is rejected as malformed there rather than ignored (silent ignore hides client bugs).
  • f (formation) rides only on move/attackMove. holdFormation takes s/l directly because its engine signature is (units, shape, leaderPos) (:375), not an opts bag.
  • assistBuild deliberately drops buildingType. The engine takes it (:420) but the codec supplies site.type from the resolved building β€” see Β§2.3. This is an example of the codec being narrower than the engine on purpose.
  • setRally.node is a nodeId or null (:531); the codec validates it exists and is discovered.
  • escort takes no role filter in the engine (:361) β€” the codec adds none either, matching current behaviour exactly, but it does reject target ∈ ids (inputCommands.js:272 filters the target out client-side; the codec does it server-side so the engine cannot be handed a self-escort).

3.5 Server-side re-validation rules

For each command the codec applies, in order:

  1. Envelope β€” v === PROTOCOL_VERSION, seq unseen for this owner, shape matches the union (unknown key β‡’ reject; JSON only, no prototypes).
  2. Subject resolution β€” ids/worker/building resolve to live entities owned by the stamped owner. Foreign β‡’ hard reject not-owner. Missing β‡’ silent drop of that id; empty result β‡’ reject empty-selection.
  3. Bounds β€” every x/y inside [0, map.width] Γ— [0, map.height] (colliders.js:31 checks the footprint but the codec checks the raw point first, so an absurd coordinate never reaches formation math).
  4. Enum β€” s ∈ FORMATION_SHAPES, l ∈ LEADER_POSITIONS (formation.js:59-60), p ∈ LOGI_PRIORITIES (haul.js:102), b ∈ Object.keys(BUILDINGS).
  5. Limits β€” ids.length ≀ 400, pts.length ≀ 32, batch.c.length ≀ 16, plus a per-owner token bucket (recommend 30 commands/sec sustained, burst 60 β€” comfortably above human APM and above the AI's own budgeted rate).
  6. Target visibility (new rule, Β§2.4):
    • own entity β‡’ no fog check.
    • foreign unit β‡’ isVisibleAt(state.fogs[owner], t.x, t.y) (fog.js:49).
    • foreign building β‡’ isExploredAt(…) (fog.js:55) β€” remembered structures stay attackable, which is standard RTS and matches what the renderer already shows.
    • node β‡’ isNodeDiscovered(state.fogs[owner], node) (fog.js:66).
  7. Delegate to engine/commands.js, unchanged. Affordability, prereqs, placement, doctrine locks and role filters stay exactly where they are β€” issueBuild:404-407 re-runs canAfford/prereqsMet/canPlaceBuilding server-side for free, because the server is the authority and the codec calls the same function the local game calls.

That last point is the payoff of D1: issueBuild's placement validation is not re-implemented in the codec at all. The codec's only job is to prove the worker belongs to the submitter; canPlaceBuilding(state, buildingType, x, y) (colliders.js:26) then runs against the authoritative state at the scheduled tick and returns null if the ground was taken in the meantime. The codec maps that null to a refused result and echoes it to the client, which rolls back its optimistic ghost. No duplicated collision logic, no drift between client preview and server truth.

3.6 Phase 2 β€” the economy commands

Same envelope, same resolver, appended to the union:

type WireCommand2 =
  | { t: "queueProduction";  building: string; u: string; alt?: boolean }
  | { t: "cancelProduction"; building: string; i: number }
  | { t: "researchUpgrade";  building: string; up: string }
  | { t: "researchTech";     building: string; tech: string }
  | { t: "cancelResearch";   building: string; i: number }
  | { t: "deployColonyShip"; ship: string }
  | { t: "packCommandCenter"; building: string }
  | { t: "lightFuse";        unit: string }
  | { t: "marketSell" | "marketBuy"; com: string; qty: number }
  | { t: "setColonyPolicy";  planet: string; patch: object };

Every one of these resolves its building/unit/ship id through the same owner-scoped resolver, which closes Β§2.2 wholesale. marketSell/marketBuy and the diplomacy verbs take no entity id at all and are scoped by the stamped owner directly β€” note market.js:164 sell(galaxy, state, com, qty) currently has no owner parameter; it will need one (or a per-owner market), which is a genuine Odyssey-scope design question and is out of scope for this document.


4. The adapter layer β€” net/commandCodec.js

4.1 Recommendation: wrap, do not change engine/commands.js

Decision: D1. Keep object-ref signatures. Add net/commandCodec.js.

Reasons, in order of weight:

  1. The object graph is not incidental. follow-leader orders carry a live Unit (commands.js:205), squadFollowers/squadLeader are a bidirectional object graph (:46-53), and the file itself documents this as transient, deliberately non-serialisable state (:43-45). "Make it id-based" is not a signature change; it is a rewrite of the squad system plus every consumer in movement.js (keepFollowingLeader, escortSlot) β€” with a real perf cost (test/perf-guard.test.js exists) from re-doing state.units.get() in the hot loop.
  2. Blast radius. 209 call sites outside engine/commands.js, across 20 files: test/commands.test.js (58), test/formation.test.js (36), test/ferry.test.js (28), test/recycle.test.js (16), inputCommands.js (13), engine/aiMilitary.js (11), input.js (6), test/sim.test.js (6), test/scout.test.js (6), test/escort.test.js (6), hudSelection.js (5), plus 9 more files. Against 2519 tests and a determinism guard, that is a multi-day change with a real chance of a silent behavioural drift that only test/determinism.test.js would catch β€” and only if the drift happens to change a fingerprinted field.
  3. The AI already holds objects. aiMilitary.js/aiEconomy.js iterate live units and pass them straight in. Forcing ids means map lookups the AI does not need, on the sim's hot path, for zero benefit β€” the AI never crosses a wire.
  4. A choke point is worth more than scattered guards. One file to audit, one file to fuzz, one file to rate-limit, one file where "did we check ownership?" has a single answer. Scattering owner parameters into 22 engine functions would also mean every AI and test call site must now supply an owner β€” the same 209-site churn, plus a permanently wider engine API.
  5. The engine stays DOM-free and pure. The codec is net/, not engine/, so test/engine-purity.test.js and the determinism guard keep their current boundary unchanged.

What we give up: the engine's public API stays "unsafe by default" β€” anyone who calls issueRecycle directly can still recycle an enemy. Mitigation: a guard test asserting that no file under net/ or server/ imports engine/commands.js except net/commandCodec.js. Same idiom as the existing test/engine-purity.test.js import walk (engine-purity.test.js:35-52).

4.2 The one exception β€” issueSetRally (D2)

issueSetRally(building, x, y, nodeId) (:531) is the only export taking a bare entity object with no state. It is a 1-line function, has 3 call sites total, does no validation whatsoever, and is the only place where the codec would otherwise have to hand a raw object across the boundary. Change it to match its id-based sibling issueSetLogiPriority(state, buildingId, priority) (:330):

export function issueSetRally(state, buildingId, x, y, nodeId = null) {
  const b = state.buildings.get(buildingId);
  if (!b) return;
  b.rally = { x, y, nodeId };
}

Cost: 3 call sites (inputCommands.js:199 + 2 tests). Benefit: the codec's entity-resolution rule becomes universal with no special case.

issueSetAILogistics(units, on, state) (:295) has an odd trailing state parameter. Leave it. It is ugly, it is not a correctness problem, and touching it buys nothing.

4.3 The codec

/* ============================================================
   net/commandCodec.js β€” the ONLY bridge between the wire and engine/commands.js.

   Wire commands are id-based, owner-scoped and tick-scheduled. This file
   resolves ids to the live objects engine/commands.js wants, proves the
   submitting owner actually owns them, and delegates. It deliberately
   re-implements NO game rule: affordability, prereqs, placement, doctrine
   locks and role filters all stay in engine/, which the server calls exactly
   as the single-player client does.

   INVARIANT: no other module under net/ or server/ may import
   engine/commands.js. See test/net-boundary.test.js.
   ============================================================ */

"use strict";

import * as cmd from "../engine/commands.js";
import { BUILDINGS } from "../engine/entities.js";
import { FORMATION_SHAPES, LEADER_POSITIONS } from "../engine/formation.js";
import { LOGI_PRIORITIES } from "../engine/haul.js";
import { isVisibleAt, isExploredAt, isNodeDiscovered } from "../engine/fog.js";

export const PROTOCOL_VERSION = 1;

export const LIMITS = {
  ids: 400,          // per-command selection cap
  patrolPoints: 32,  // engine/commands.js:484 pushes |ids| x |pts| orders β€” must be bounded
  batch: 16,
};

export const REJECT = {
  BAD_VERSION:  "bad-version",
  UNKNOWN_TYPE: "unknown-type",
  MALFORMED:    "malformed",
  TOO_MANY:     "too-many",
  NOT_OWNER:    "not-owner",       // a LIE β€” the submitter does not own this entity
  NO_TARGET:    "no-target",       // the object of the command does not exist
  NOT_VISIBLE:  "not-visible",     // fog gate (a rule the engine does not have)
  EMPTY:        "empty-selection", // every id resolved to nothing (a legitimate race)
  OUT_OF_BOUNDS:"out-of-bounds",
  REFUSED:      "refused",         // the ENGINE said no (afford / prereq / placement)
};

/* ---------- primitives ---------- */

const isId  = v => typeof v === "string" && v.length > 0 && v.length <= 32;
const isNum = v => typeof v === "number" && Number.isFinite(v);
const bool  = v => v === undefined || typeof v === "boolean";

const ok   = (result = null) => ({ ok: true, result });
const err  = code => ({ ok: false, code });

function inBounds(state, x, y) {
  return isNum(x) && isNum(y) && x >= 0 && y >= 0 && x <= state.map.width && y <= state.map.height;
}

/* ---------- resolvers: id -> live object, scoped to `owner` ----------

   Two failure modes, deliberately different:
     - the entity is GONE      -> drop that id (a real race between issue and apply)
     - the entity is SOMEONE ELSE'S -> reject the whole command (a lie)
   Rejecting on "gone" would make the protocol fail under ordinary latency;
   dropping on "not yours" would let an attacker probe the world for free.

   ORDER IS PRESERVED. ids[0] is the formation leader (engine/commands.js:145,
   :195) and issueEscort derives ring slots from array index (:363). Never sort.
*/

function resolveOwn(state, owner, ids, pick) {
  if (!Array.isArray(ids) || ids.length === 0) return err(REJECT.EMPTY);
  if (ids.length > LIMITS.ids) return err(REJECT.TOO_MANY);
  const seen = new Set();
  const out = [];
  for (const id of ids) {
    if (!isId(id)) return err(REJECT.MALFORMED);
    if (seen.has(id)) continue;                 // dedupe, first occurrence wins
    seen.add(id);
    const e = pick(state, id);
    if (!e) continue;                           // died in flight β€” drop
    if (e.owner !== owner) return err(REJECT.NOT_OWNER);
    out.push(e);
  }
  return out.length ? ok(out) : err(REJECT.EMPTY);
}

const pickUnit     = (s, id) => s.units.get(id);
const pickBuilding = (s, id) => s.buildings.get(id);
const pickEntity   = (s, id) => s.units.get(id) || s.buildings.get(id);

const ownUnits      = (s, o, ids) => resolveOwn(s, o, ids, pickUnit);
const ownEntities   = (s, o, ids) => resolveOwn(s, o, ids, pickEntity);   // recycle takes both

function ownBuilding(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const b = state.buildings.get(id);
  if (!b) return err(REJECT.NO_TARGET);
  if (b.owner !== owner) return err(REJECT.NOT_OWNER);
  return ok(b);
}

function ownUnit(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const u = state.units.get(id);
  if (!u) return err(REJECT.NO_TARGET);
  if (u.owner !== owner) return err(REJECT.NOT_OWNER);
  return ok(u);
}

/* Any entity as the OBJECT of a command. Own entities need no fog check; a
   foreign unit must be currently visible, a foreign building merely explored
   (remembered structures stay targetable β€” standard RTS, and it is what the
   renderer already draws). This rule does not exist in the engine at all:
   inputCommands.js:58,:62 enforces it in the UI only, so a client that ignores
   its own renderer is a maphack today. */
function targetEntity(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const e = pickEntity(state, id);
  if (!e) return err(REJECT.NO_TARGET);
  if (e.owner === owner) return ok(e);
  const fog = state.fogs[owner];
  const seen = e.kind === "building" ? isExploredAt(fog, e.x, e.y) : isVisibleAt(fog, e.x, e.y);
  return seen ? ok(e) : err(REJECT.NOT_VISIBLE);
}

function targetNode(state, owner, id) {
  if (!isId(id)) return err(REJECT.MALFORMED);
  const n = state.map.nodes.find(n => n.id === id);
  if (!n) return err(REJECT.NO_TARGET);
  return isNodeDiscovered(state.fogs[owner], n) ? ok(n) : err(REJECT.NOT_VISIBLE);
}

/* ---------- formation ---------- */

function decodeFormation(f) {
  if (f === undefined) return undefined;                 // engine default: flat grid spread
  if (f === null || typeof f !== "object") return null;  // null => malformed
  const { s = "grid", l = "front", hx, hy } = f;
  if (!FORMATION_SHAPES.includes(s)) return null;
  if (!LEADER_POSITIONS.includes(l)) return null;
  if (hx !== undefined && !isNum(hx)) return null;
  if (hy !== undefined && !isNum(hy)) return null;
  const out = { shape: s, leaderPos: l };
  if (hx !== undefined) out.headingX = hx;               // the right-click-DRAG facing (commands.js:218)
  if (hy !== undefined) out.headingY = hy;
  return out;
}

/* ---------- the schema table ----------
   One entry per wire type. `run` receives already-resolved, already-owned
   objects and does nothing but call engine/commands.js. Everything that could
   reject has already rejected. */

const SCHEMA = {

  /* ----- movement ----- */
  move: { run(state, owner, c) {
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const f = decodeFormation(c.f); if (f === null) return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueMove(r.result, c.x, c.y, !!c.q, f);
    return ok();
  }},

  attackMove: { run(state, owner, c) {
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const f = decodeFormation(c.f); if (f === null) return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueAttackMove(r.result, c.x, c.y, !!c.q, f);
    return ok();
  }},

  holdFormation: { run(state, owner, c) {
    const s = c.s ?? "grid", l = c.l ?? "front";
    if (!FORMATION_SHAPES.includes(s) || !LEADER_POSITIONS.includes(l)) return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueHoldFormation(r.result, s, l);   // anchor = live centroid, commands.js:377
    return ok();
  }},

  patrol: { run(state, owner, c) {
    if (!Array.isArray(c.pts) || !c.pts.length) return err(REJECT.MALFORMED);
    if (c.pts.length > LIMITS.patrolPoints) return err(REJECT.TOO_MANY);
    for (const p of c.pts) if (!p || !inBounds(state, p.x, p.y)) return err(REJECT.OUT_OF_BOUNDS);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issuePatrol(r.result, c.pts.map(p => ({ x: p.x, y: p.y })));   // strip any extra keys
    return ok();
  }},

  stop:  { run: (s, o, c) => unitsOnly(s, o, c, us => cmd.issueStop(us)) },
  hold:  { run: (s, o, c) => unitsOnly(s, o, c, us => cmd.issueHold(us)) },
  scout: { run: (s, o, c) => unitsOnly(s, o, c, us => cmd.issueScout(us)) },

  /* ----- targeted ----- */
  attack: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = targetEntity(state, owner, c.target); if (!t.ok) return t;
    const r = ownUnits(state, owner, c.ids);        if (!r.ok) return r;
    cmd.issueAttack(r.result, t.result.id, !!c.q);
    return ok();
  }},

  escort: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = targetEntity(state, owner, c.target); if (!t.ok) return t;
    const r = ownUnits(state, owner, c.ids);        if (!r.ok) return r;
    // inputCommands.js:272 filters the target out of its own escort ring; do the
    // same here so the engine can never be handed a unit escorting itself.
    const units = r.result.filter(u => u.id !== t.result.id);
    if (!units.length) return err(REJECT.EMPTY);
    cmd.issueEscort(units, t.result.id, !!c.q);
    return ok();
  }},

  repair:  { run: (s, o, c) => targeted(s, o, c, (us, id, q) => cmd.issueRepair(us, id, q)) },
  service: { run: (s, o, c) => targeted(s, o, c, (us, id, q) => cmd.issueServiceBuilding(us, id, q)) },

  ferry: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = ownUnit(state, owner, c.target); if (!t.ok) return t;   // your OWN freighter only
    const r = ownUnits(state, owner, c.ids);   if (!r.ok) return r;
    cmd.issueFerryFreighter(r.result, t.result.id, !!c.q);
    return ok();
  }},

  setHomeBase: { run(state, owner, c) {
    const t = ownBuilding(state, owner, c.target); if (!t.ok) return t;
    // The engine never validates ccId is even a building (commands.js:281-287).
    if (t.result.type !== "command") return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueSetHomeBase(r.result, t.result.id);
    return ok();
  }},

  assistBuild: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const t = ownBuilding(state, owner, c.target); if (!t.ok) return t;
    if (!t.result.constructing) return err(REJECT.NO_TARGET);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    // buildingType comes from the RESOLVED SITE, never from the wire β€” it is the
    // only thing gating unit eligibility (commands.js:421) and a lying client
    // would otherwise walk a combat unit onto a site the engine refuses.
    cmd.issueAssistBuild(r.result, t.result.id, t.result.type, !!c.q);
    return ok();
  }},

  gather: { run(state, owner, c) {
    if (!bool(c.q)) return err(REJECT.MALFORMED);
    const n = targetNode(state, owner, c.node); if (!n.ok) return n;
    const r = ownUnits(state, owner, c.ids);    if (!r.ok) return r;
    cmd.issueGather(r.result, n.result.id, !!c.q);
    return ok();
  }},

  /* ----- construction ----- */
  build: { run(state, owner, c) {
    if (typeof c.b !== "string" || !BUILDINGS[c.b]) return err(REJECT.MALFORMED);
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    const w = ownUnit(state, owner, c.worker); if (!w.ok) return w;
    // EVERYTHING else β€” odysseyOnly, canBuildCategory, canAfford, prereqsMet,
    // canPlaceBuilding, payCost β€” is re-run by the engine against authoritative
    // state at THIS tick (commands.js:396-412). We re-implement none of it.
    const id = cmd.issueBuild(state, w.result.id, c.b, c.x, c.y);
    return id ? ok({ buildingId: id }) : err(REJECT.REFUSED);
  }},

  recycle:       { run: (s, o, c) => entitiesOnly(s, o, c, es => cmd.issueRecycle(es)) },
  cancelRecycle: { run: (s, o, c) => entitiesOnly(s, o, c, es => cmd.issueCancelRecycle(es)) },

  /* ----- toggles ----- */
  setAILogistics: { run(state, owner, c) {
    if (typeof c.on !== "boolean") return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueSetAILogistics(r.result, c.on, state);   // tech gate is owner-correct at commands.js:298
    return ok();
  }},

  setCollectPoint: { run(state, owner, c) {
    if (typeof c.on !== "boolean") return err(REJECT.MALFORMED);
    const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
    cmd.issueSetCollectPoint(r.result, c.on);
    return ok();
  }},

  setLogiPriority: { run(state, owner, c) {
    if (!LOGI_PRIORITIES.includes(c.p)) return err(REJECT.MALFORMED);
    const b = ownBuilding(state, owner, c.building); if (!b.ok) return b;
    cmd.issueSetLogiPriority(state, b.result.id, c.p);   // engine has NO owner check (commands.js:330)
    return ok();
  }},

  setRally: { run(state, owner, c) {
    if (!inBounds(state, c.x, c.y)) return err(REJECT.OUT_OF_BOUNDS);
    const b = ownBuilding(state, owner, c.building); if (!b.ok) return b;
    let nodeId = null;
    if (c.node !== undefined && c.node !== null) {
      const n = targetNode(state, owner, c.node); if (!n.ok) return n;
      nodeId = n.result.id;
    }
    cmd.issueSetRally(state, b.result.id, c.x, c.y, nodeId);   // see D2: signature changed
    return ok();
  }},
};

/* ---------- shared shapes ---------- */

function unitsOnly(state, owner, c, fn) {
  const r = ownUnits(state, owner, c.ids); if (!r.ok) return r;
  fn(r.result); return ok();
}
function entitiesOnly(state, owner, c, fn) {
  const r = ownEntities(state, owner, c.ids); if (!r.ok) return r;
  fn(r.result); return ok();
}
function targeted(state, owner, c, fn) {
  if (!bool(c.q)) return err(REJECT.MALFORMED);
  const t = targetEntity(state, owner, c.target); if (!t.ok) return t;
  const r = ownUnits(state, owner, c.ids);        if (!r.ok) return r;
  fn(r.result, t.result.id, !!c.q); return ok();
}

/* ============================================================
   PUBLIC API
   ============================================================ */

/** Client side: stamp an envelope around a WireCommand. No engine access. */
export function encode(command, seq, clientTick) {
  return { v: PROTOCOL_VERSION, seq, tick: clientTick, cmd: command };
}

/** Server side: envelope shape only. Does NOT touch game state β€” this runs on
 *  ARRIVAL, so a malformed packet is dropped before it can be scheduled. */
export function decode(envelope) {
  if (!envelope || typeof envelope !== "object") return err(REJECT.MALFORMED);
  if (envelope.v !== PROTOCOL_VERSION) return err(REJECT.BAD_VERSION);
  if (!Number.isInteger(envelope.seq) || envelope.seq < 0) return err(REJECT.MALFORMED);
  const c = envelope.cmd;
  if (!c || typeof c !== "object" || typeof c.t !== "string") return err(REJECT.MALFORMED);
  if (c.t === "batch") {
    if (!Array.isArray(c.c) || !c.c.length || c.c.length > LIMITS.batch) return err(REJECT.TOO_MANY);
    for (const sub of c.c) {
      if (!sub || typeof sub.t !== "string" || sub.t === "batch") return err(REJECT.MALFORMED);
      if (!SCHEMA[sub.t]) return err(REJECT.UNKNOWN_TYPE);
    }
    return ok(c);
  }
  if (!SCHEMA[c.t]) return err(REJECT.UNKNOWN_TYPE);
  return ok(c);
}

/** Server side: apply ONE validated command against live state, as `owner`.
 *  Runs at the scheduled tick, immediately before tick(state, dt). */
export function apply(state, owner, command) {
  if (command.t === "batch") {
    // Atomic on VALIDATION: a batch is one client gesture resolved against one
    // observed state (inputCommands.js:90-96 fans a right-click into two calls).
    // If any member is invalid the client's disambiguation was wrong, so none apply.
    const results = [];
    for (const sub of command.c) {
      const r = SCHEMA[sub.t].run(state, owner, sub);
      if (!r.ok) return r;
      results.push(r.result);
    }
    return ok(results);
  }
  const entry = SCHEMA[command.t];
  if (!entry) return err(REJECT.UNKNOWN_TYPE);
  return entry.run(state, owner, command);
}

export const COMMAND_TYPES = Object.keys(SCHEMA);

Note the batch caveat. A batch validates-then-applies member by member, so a member that fails after an earlier one already mutated state is not rolled back. That is acceptable for the only batch we actually generate (aggressiveMove's two disjoint sub-selections, inputCommands.js:94-95), where member 2's validity does not depend on member 1. If a future batch needs true atomicity, validate all members against a dry-run resolver first, then apply β€” but do not build that until something needs it.


5. Determinism of application order

5.1 The ordering rule

Commands arrive out of order from N clients over N sockets. The authoritative total order is:

sort key = (applyTick, ownerIndex, seq)

  applyTick   integer, stamped by the server at admission (Β§5.2)
  ownerIndex  state.owners.indexOf(owner)  -- engine/state.js:227,
              "the world's side ids, in canonical iteration order"
  seq         the client's per-connection monotonic counter

This is a total order: (owner, seq) is unique by construction (duplicates are dropped idempotently at admission), so no two records ever tie.

Why ownerIndex and not arrival time. Arrival time is wall clock; the engine-purity guard forbids wall clock in the sim (test/engine-purity.test.js:16 bans Date.now), and more importantly a replay must not depend on network jitter. state.owners is a stable, seed-independent array that already drives every other owner-generic loop in the engine (state.js:269-270, sim.js, victory.js).

Why seq and not a content hash. seq preserves the client's own intent order, which is load-bearing: a player who queues move then attackMove on the same units in the same tick means something different from the reverse.

Fairness note. ownerIndex gives seat 0 a systematic advantage in the rare case of two players issuing conflicting commands in the same tick (e.g. both right-clicking the last unclaimed node). This is a known, accepted asymmetry in every lockstep RTS and is far smaller than the network jitter it replaces. If it ever matters, rotate the ownerIndex offset by applyTick % owners.length β€” but do not do this speculatively; it makes replay logs harder to reason about.

5.2 Where the tick stamp comes from

The server stamps it. The client's tick field is never trusted.

const INPUT_DELAY_TICKS = 3;   // 150ms at 20Hz β€” covers typical RTT + jitter
applyTick = state.tick + INPUT_DELAY_TICKS;

Because the architecture is server-authoritative (not peer lockstep), the server does not need clients to agree on a future tick β€” it only needs the choice to be recorded. The stamp depends on wall-clock arrival, which is nondeterministic during the live match but is written into the log, so replay from the log is exact (Β§7). This is strictly simpler and more robust than honouring a client-proposed tick, which requires rejecting late commands and opens a "schedule everything at tick+1000" griefing vector.

INPUT_DELAY_TICKS exists so a command is visible to every spectator/relay before it lands, and so the server can batch a tick's worth of input into one sorted list. Set it to 0 and the protocol still works; set it to 3 and the spectator stream can stay a whole tick behind the sim without stuttering.

5.3 Where in the loop

/* server/matchLoop.js β€” the ONLY place a wire command reaches the sim. */
import { tick } from "../engine/sim.js";
import { apply } from "../net/commandCodec.js";

function ownerIndex(state, owner) { return state.owners.indexOf(owner); }

/** Pull every command scheduled at or before `state.tick`, order it, apply it,
 *  append it to the log, THEN advance the sim by one fixed step. */
export function stepMatch(match, dt) {
  const { state, pending, log } = match;

  const due = pending.filter(r => r.applyTick <= state.tick);
  if (due.length) {
    // A command whose tick has already passed (a slow admission, a resumed
    // socket) still lands here rather than being dropped β€” but it sorts by its
    // ORIGINAL applyTick, so the log stays monotonic and the replay is exact.
    due.sort((a, b) =>
      a.applyTick - b.applyTick ||
      ownerIndex(state, a.owner) - ownerIndex(state, b.owner) ||
      a.seq - b.seq);
    for (const rec of due) {
      const res = apply(state, rec.owner, rec.cmd);
      rec.result = res.ok ? res.result : { rejected: res.code };
      rec.appliedAtTick = state.tick;   // == applyTick in the normal case
      log.push(rec);
      match.emitAck(rec);
    }
    match.pending = pending.filter(r => r.applyTick > state.tick);
  }

  tick(state, dt);          // <- the sim advances AFTER every command for this tick
}

Why before tick, never inside it. tick(state, dt) opens with runAI(state, dt) (engine/sim.js:39-40), which issues its own orders through the same issue* functions. In single-player, DOM input handlers fire between update() calls (JS is single-threaded; createLoop's update(dtFixed) at engine/loop.js:55 runs to completion). So "commands land between ticks, before the AI thinks" is exactly the existing single-player ordering. Applying them mid-tick β€” e.g. after runAI but before movement β€” would be a behavioural change with no justification, and would break the ability to validate the netcode path against tools/selfplay.js.

Rejected commands are logged too. A rejection is a fact about the match (anti-cheat forensics, and a spectator needs to know why nothing happened). It carries no state mutation, so it does not affect replay β€” but it must be present in the log for the log to be auditable.

5.4 The fixed step

The server must pick one dt and never change it. tools/selfplay.js:42-57 documents in detail why: "A fixed step IS the simulation… dt 0.1 ended 'ai' by elimination at 1138 s, dt 0.05 ended 'player' by elimination at 1686 s β€” opposite winners."

Recommendation: dt = 0.05 (20 Hz), matching createLoop's default (engine/loop.js:34) and ordinary play. SELFPLAY_DT = 0.1 exists for throughput on the AI bench, not for fidelity. Record the chosen dt in the match header (Β§7) so a replay cannot be run at the wrong step.


6. state.selection

6.1 Who reads it β€” the grep

state.selection is declared at engine/state.js:231 as selection: [] // unit/building ids currently selected by the human player, and typed at engine/types.js:335.

Readers β€” all UI, none in the sim:

File Lines
inputCommands.js 103, 147, 150, 152, 154, 157, 177, 195, 196, 206
renderEffects.js 544, 561, 562, 598, 635
hudSelection.js 76 (and the whole panel-signature system downstream)
render.js 132
techChart.js 258
boot.js 223

Writers inside engine/:

File Line What
engine/state.js 347 removeEntity prunes the dead id out of state.selection
engine/galaxy.js 1422 from.selection = []; dest.selection = [] on an interplanetary jump

No file under engine/ ever READS state.selection. It is written defensively (so the UI never holds a dangling id) and read only by the client. The brief's premise is confirmed.

6.2 Where it must move β€” and what stays

Decision (D6): selection is per-client UI state and moves to the client session. The State field stays, permanently empty, on the server.

  • Client (browser): client/session.js grows selection: string[], owned by the input layer. inputCommands.js's applyBoxSelection / selectedUnits / commandAt read it from there instead of from state. Every one of those call sites is already local to the client β€” this is a find-and-replace, not a redesign.
  • MCP agent: an agent has no pointer and no box-select. Its "selection" is whatever id array it puts in a command. The MCP server should expose a convenience selection in the agent session (so an agent can say "select all my Lancers, then attack-move") but it must be an MCP-server concept, never a sim concept, and it must be re-validated by the codec on every command anyway.
  • Server: state.selection stays as [] forever. Do not delete the field. removeEntity (state.js:345-348) is on the hot path of every death in the game; making it conditional, or removing the line, means touching engine/state.js, engine/galaxy.js:1422, engine/types.js:335, the persistence layer and any test that asserts on selection pruning β€” for a saving of one array filter over an always-empty array. Not worth it.

Guard test: assert that no file under server/ or net/ contains state.selection, using the same directory-walk idiom as test/engine-purity.test.js:35-52. That converts D6 from a convention into an enforced invariant.

One subtlety that must be carried across: selection order is game input (Β§3.3). applyBoxSelection's Ctrl-click promote-to-front (inputCommands.js:150-152) is how a player picks a formation leader. When selection moves client-side, that ordering must still be what the client puts in cmd.ids β€” otherwise leaders silently change and formations break.


7. Replay & spectator

7.1 Can a seed + ordered command log replay the match exactly?

Yes β€” after B1 in Β§8 is fixed. Not before.

The engine is built for this. engine/rng.js:5-6 states the sim uses "NO other randomness (a determinism-guard test enforces it), so 'same seed β‡’ same game'", test/engine-purity.test.js:16 bans Math.random/Date.now/performance.now across engine/, and test/determinism.test.js:22-29 proves 2500 ticks replay byte-identically from one seed.

7.2 What a replay file must capture

{
  "replayVersion": 1,
  "engineCommit": "50ceb88",              // MUST match; balance changes invalidate a replay
  "protocolVersion": 1,

  "sim": {
    "dt": 0.05,                            // Β§5.4 β€” a different step is a DIFFERENT GAME
    "createGameState": {                   // every argument of engine/state.js:154
      "planetId": "ferros",
      "seed": 12345,                       // feeds mulberry32 (engine/rng.js:24)
      "sizeMult": 1, "resourceMult": 1, "swapAsym": false,
      "matchTimeLimit": null, "popCap": null, "endless": false,
      "difficulty": "medium", "aiArchetype": null,
      "playerFaction": "…", "aiFaction": "…"
    },
    "postCreate": [                        // mutations applied AFTER createGameState returns
      { "fn": "seedDifficultyEdge", "owner": "p1" },   // engine/state.js:299
      { "fn": "createAiController", "owner": "p3", "opts": { "apm": 120, "micro": true,
                                                             "strategy": "default",
                                                             "difficulty": "hard",
                                                             "archetype": "…" } }
    ]
  },

  "seats": [ { "owner": "p1", "kind": "human",  "label": "alma" },
             { "owner": "p2", "kind": "agent",  "label": "mcp:claude-1" },
             { "owner": "p3", "kind": "ai",     "label": "scripted" } ],

  "commands": [ /* log records from Β§3.1, already in (applyTick, ownerIndex, seq) order */ ],

  "checkpoints": [ { "tick": 200,  "fp": "…" },   // tools/selfplay.js:157 fingerprint()
                   { "tick": 400,  "fp": "…" } ],

  "outcome": { "tick": 18342, "winner": "p1", "winReason": "elimination" }
}

7.3 What is easy to forget β€” and fatal if forgotten

  1. dt. Covered above. Put it in the header and refuse to replay without it.
  2. Every createGameState option, not just the seed. sizeMult, resourceMult and swapAsym all feed generateMap (state.js:161-165) and change the world.
  3. Post-createGameState mutations. seedDifficultyEdge (state.js:299) writes players[owner].upgrades.hardEdge after construction, and state.playerAi is "populated after createGameState, never by it" (types.js:340-341). A replay that only records constructor args reproduces a different world. tools/selfplay.js:36 already imports all three functions for exactly this reason β€” copy that pattern.
  4. AI seat configuration. {apm, micro, strategy, difficulty, archetype} per AI seat (tools/selfplay.js:69-74). The scripted AI is a player in a replay and its dials are inputs.
  5. The rejected commands. Keep them; they are audit evidence and they cost nothing to replay (they no-op).
  6. The engine commit. A balance tweak in entities.js silently invalidates every stored replay. Refuse to replay across a commit mismatch rather than producing a plausible lie.
  7. Periodic fingerprints. Reuse fingerprint(state) (tools/selfplay.js:157-169) verbatim β€” it already covers units (id, type, owner, x, y, hp, order type), buildings, per-owner resources, fog totals, both AI controllers, tick, time, over, winner. Store one every N ticks. On replay, a mismatch localises the divergence to an N-tick window instead of "somewhere in 20 minutes". For live play, comparing the server fingerprint against a client's own prediction is the desync canary. Note: test/determinism.test.js:9-15 warns that a weaker local snapshot once masked real drift, and test/_helpers.js's entitySnapshot is the stronger one. For stored checkpoints, prefer entitySnapshot; fingerprint is the cheap live variant.

7.4 Spectator

A spectator is a replay consumer with a live tail: subscribe to the same ordered command stream plus periodic entitySnapshot keyframes, and run the identical stepMatch loop locally. That gives full-fidelity spectating at command bandwidth (a few hundred bytes/sec) rather than state bandwidth.

Fog is the catch. A spectator running the real sim has the whole state, including every player's fog. Client-side fog filtering is not a security boundary. Two honest options:

  • Deferred spectating (recommended for v1): spectators are N seconds behind and receive the stream, but the relay withholds commands whose subject is not yet visible to the spectator's chosen POV. Simple, and matches how most RTS observers work.
  • Server-rendered POV: the server maintains one fog-filtered state view per spectator POV and ships deltas. Correct, but expensive and a much bigger build.

Do not ship "full state, hidden by the client" β€” in a competitive ladder that is a maphack with extra steps.


8. Blockers found β€” fix before the codec ships

B1 β€” nextEntityId is a module-global (critical)

engine/state.js:23-24:

let nextEntityId = 1;
function newId(prefix) { return `${prefix}${nextEntityId++}`; }

reset at createGameState (state.js:155). The comment at state.js:17-22 reasons that "IDs are only ever compared within one state's own Maps, so two live games sharing id strings is harmless" β€” true for two games, false for two games in one process. A single Node server hosting concurrent matches will interleave newId calls across matches, so match A's units are minted u57, u59, u62… depending on what match B did. Entity ids feed the deterministic tie-breaks in movement/separation/gather/rankSlotsByRange (commands.js:105 sorts by a.id < b.id), so the same seed and the same command log produce a different match depending on what else the server was hosting. Replay, spectating and any ladder rating built on them are all invalid.

Worse, createGameState resets the counter to 1 β€” so starting match B mid-match A makes A start minting ids that collide with its own live entities.

Fix (small, surgical): move the counter onto the state. Either state.nextEntityId with makeUnit/makeBuilding taking state, or a per-match id-minter closure passed into createGameState. The existing peekEntityId/restoreEntityId pair (state.js:29-31) shows persistence already treats it as per-game state β€” this makes that real. Guard with a test that two interleaved createGameState runs each replay identically.

Until this is fixed, run one match per Node process (worker/child process per match). That is a legitimate v1 shipping posture on a Hugging Face Space and sidesteps B1 entirely β€” but write it down as a constraint, not an accident.

B2 β€” formations are gated on owner === "player" (critical)

engine/commands.js:155 (Β§1.4). In a multi-seat match no owner is literally "player", so dispatchFormation takes the AI branch for everyone and the leader/follower squad system silently disappears.

Fix: replace the literal test with a state-level predicate β€” e.g. state.seats[owner]?.kind !== "ai", or a humanControlled flag set at match creation. The comment block at :147-154 explains the intent precisely ("there's no analogous 'the unit you built a selection around' concept for the scripted AI"), so the predicate is seat kind, not owner name. Small change, but it changes AI-vs-AI fingerprints if done carelessly β€” gate it so the existing "player"/"ai" two-owner world behaves byte-identically.

B3 β€” tick hardcodes two fog owners (high)

engine/sim.js:70-71:

updateFog(state, state.fog, "player");
updateFog(state, state.fogAI, "ai");

state.fogs is already a per-owner map (state.js:189, types.js:336) and createGameState iterates owners correctly (state.js:269-270) β€” but the tick loop does not. Seats 3+ get no fog updates at all. Fix by iterating state.owners. test/ownerScaffold.test.js exists and is the right place to extend. (engine/victory.js, engine/diplomacy.js and engine/galaxy.js carry similar two-owner assumptions β€” worth a dedicated audit, out of scope here.)

B4 β€” recycle.js's ownership comment is false (doc bug, real risk)

engine/recycle.js:87-89 claims issueRecycle checks ownership. It does not (commands.js:444-450). Fix the comment when the codec lands, or a future reader will build on a guarantee that isn't there.

B5 β€” explicit attack orders are friendly-fire capable (medium)

engine/combat.js:46 takes unit.order.targetId with no owner filter and reaches performAttack (:85) without one, while every auto-acquisition path does filter (:153, :243, :386, :411). Harmless in single-player (the UI never offers it, inputCommands.js:247-251). In multiplayer it is a team-griefing vector and an own-goal footgun. Decide deliberately: either add the filter in the codec (reject attack on a target whose owner is the submitter or an ally), or in combat.js. Recommend the codec β€” keeping the engine byte-identical protects the determinism baseline, and "can I shoot my ally" is a rules question that belongs with the other rules the codec owns.


9. TDD hooks (the repo's house style)

The source repo is strict TDD with 2519 tests and a determinism guard. Land the codec the same way:

Test file Asserts
test/net-boundary.test.js Only net/commandCodec.js imports engine/commands.js from outside engine/. Directory-walk idiom from engine-purity.test.js:35-52.
test/commandCodec-ownership.test.js For every entry in COMMAND_TYPES, a command naming another owner's entity returns NOT_OWNER and mutates nothing. Table-driven over COMMAND_TYPES so a new command type cannot be added without an ownership test.
test/commandCodec-fog.test.js Foreign unit not visible β‡’ NOT_VISIBLE; foreign building explored-but-not-visible β‡’ allowed; undiscovered node β‡’ NOT_VISIBLE.
test/commandCodec-schema.test.js Round-trip encode→decode; unknown type, bad version, oversized ids/pts/batch all rejected; ids order is preserved (the D4 guard).
test/commandCodec-parity.test.js Driving a match through the codec produces the byte-identical entitySnapshot as driving the same orders through issue* directly. This is the proof that wrapping changed nothing.
test/replay.test.js Seed + logged commands replay to an identical entitySnapshot; a one-tick shift in any applyTick diverges (proving the guard is real, mirroring determinism.test.js:43-47's "different seeds diverge" check).
test/matchLoop-order.test.js Commands submitted in scrambled arrival order apply in (applyTick, ownerIndex, seq) order; duplicate (owner, seq) is idempotent.

10. Summary of recommendations

  1. Wrap engine/commands.js behind net/commandCodec.js. Do not make the engine id-based. (Β§4.1)
  2. One engine signature change: issueSetRally(state, buildingId, x, y, nodeId). (Β§4.2)
  3. Server stamps owner and applyTick. Never trust either from the client. (Β§3.1, Β§5.2)
  4. Order is (applyTick, ownerIndex, seq); apply immediately before tick(state, dt). (Β§5)
  5. ids arrays are ordered input β€” never sort them. (Β§3.3, D4)
  6. Add fog gating in the codec β€” it exists nowhere in the engine. (Β§2.4, Β§3.5)
  7. state.selection moves to the client session; the field stays empty on the server. (Β§6)
  8. Fix B1 (nextEntityId) before any concurrent hosting, or run one match per process until it is fixed. (Β§8)
  9. Fix B2 (owner === "player" formation gate) or multiplayer ships without formations. (Β§8)
  10. Cover both intent surfaces. engine/commands.js is phase 1; the ~20 cost-bearing mutators the HUD calls directly (hudSelection.js:20-35) are phase 2, through the same codec. (Β§1.6, Β§3.6)