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 `removeEntity`s 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`:
```js
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**:
```js
// 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.
```jsonc
{
"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**:
```jsonc
{
"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.**
```jsonc
{ "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
```ts
/** 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)
```ts
/** 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:
```ts
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`):
```js
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
```js
/* ============================================================
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.**
```js
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
```js
/* 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
```jsonc
{
"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`:
```js
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`:
```js
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)