2. Wire protocol (JSON and binary snapshots over WebSocket)

Client-to-server messages and reliable server-to-client messages are JSON objects with a t field (the discriminator/tag). Live snapshot messages are MessagePack compact binary snapshot frames over the same WebSocket. Field names are short but readable. Coordinates are world pixels (floats) unless a field name ends in Tile. The canonical Rust definitions live in server/crates/protocol/src/lib.rs; the server-shell server/src/protocol.rs is an adapter for typed entity-kind conversion and legacy imports. The browser mirror lives in client/src/protocol.js (builders + constants). Rust and JS MUST agree on every tag, field name, and compact transport shape.

This is a pre-alpha, latest-version-only protocol. It may change incompatibly with older clients, servers, and replay artifacts; keep the current Rust and JS mirrors synchronized instead of carrying compatibility shims for old builds by default.

rts-protocol may depend on rts-contract but must not depend on rts-sim, rts-rules, rts-ai, or rts-server. Domain kind conversion that needs EntityKind belongs in an adapter layer such as server/src/protocol.rs or server/crates/sim/src/protocol.rs, not in the wire DTO crate.

2.0 Boundary authority and guardrails

server/crates/protocol/src/lib.rs owns the wire DTO vocabulary, message tags, compact code tables, compact slot schemas, COMPACT_SNAPSHOT_VERSION, PREDICTION_PROTOCOL_VERSION, and the unknown compact-code sentinel (255). server/crates/contract/src/lib.rs owns shared semantic DTOs that the protocol crate re-exports, including start/snapshot contract records and DEFAULT_FACTION_ID. rts-rules::EntityKind::stable_id() owns domain identity strings; rules- or sim-aware conversion into protocol kind strings belongs in adapter modules, not in rts-protocol.

client/src/protocol.js is the browser mirror for protocol vocabulary, compact decode tables, message builders, and decode helpers. Protocol changes must update Rust DTOs or dumps, the JS mirror, this design file, and focused parity coverage in the same commit. Compact slot order is append-only unless the compact snapshot version is intentionally bumped and the Rust serializer, JS decoder, parity fixture, and docs change together.

Run node tests/protocol_parity.mjs after any protocol vocabulary, compact code, compact slot, start/snapshot/replay DTO, prediction metadata, default faction id, or lobby color palette change. That check compares the structured Rust protocol contract dump to the JS mirror, rejects duplicate or sentinel compact codes, decodes representative compact fixtures, and also checks PLAYER_PALETTE against server/src/lobby/mod.rs. The palette is not a wire-protocol constant, but the server assigns lobby/start colors and the client keeps a fallback/render mirror, so the cross-surface guard intentionally lives with the protocol parity smoke test until a structured lobby/config dump replaces the source scrape.

2.1 Client → Server (ClientMessage)

tFieldsMeaning
joinname: string, room?: string, spectator?: bool, replayOk?: boolJoin (or create) a room as an active lobby player or, when spectator is true, as a lobby observer. room defaults to "main". Spectator joins and lobby role switches are observer-only and must happen before match start. If the target room is replay playback, the first join is rejected with joinReplayPrompt; retry with replayOk: true only after user confirmation. If the same WebSocket is already in a different room and the new room accepts the join, the connection transfers to the new room and leaves the previous room.
readyready: boolToggle ready state in the lobby.
startHost asks to start the match (only honored from the room host).
setTeamPresetpreset: stringDeprecated compatibility command. The server ignores it; lobby teams are host-managed slots.
setTeamid: u32, teamId: u32Host assigns an active human or AI lobby seat to team 1..=4 (lobby phase only, host-only). Unknown ids, spectators, team id 0, and team ids outside the supported range are ignored.
setFactionfactionId: stringActive human players select their own playable lobby faction (lobby phase only). Unknown ids, fixture ids, spectators, countdown, and in-game requests are ignored. The normal client only exposes this during the beta UI rollout.
addAiteamId?: u32, aiProfileId?: stringHost adds a computer opponent to the room (lobby phase only, host-only). When teamId is provided it must be in 1..=4; otherwise the server assigns the first empty team slot. aiProfileId may be one of the supported live AI profiles; omitted or unknown values default to the highest supported live AI version.
setAiProfileid: u32, aiProfileId: stringHost selects the live AI profile for an existing AI lobby seat (lobby phase only, host-only). Unknown AI ids and unsupported profile ids are ignored.
removeAiid: u32Host removes a previously-added AI opponent by id (lobby phase only, host-only).
setQuickstartenabled: boolHost toggles “Debug mode” for the next match in this room.
setSpectatorspectator: bool, id?: u32Switch between active player and spectator role while still in the lobby. When id is omitted, the sender switches their own role. The host may include another connected human player’s id to move that lobby player into or out of spectators; non-host targeted requests, AI ids, and unknown ids are ignored. Ignored after the match starts; switching to active player is ignored if the active seats are full.
commandclientSeq: u32, cmd: CommandIssue a gameplay command (see below). Ignored unless in-game. clientSeq is a browser-local, per-match, per-connection sequence id for prediction/reconciliation and diagnostics-only command receipts.
giveUpGive up the active match. The server eliminates that player and sends their score screen.
pauseGamePause a live match. Honored only from active live players while the room is unpaused and that active seat has successful pause starts remaining.
unpauseGameResume a paused live match. Honored from any active live player while the room is paused.
returnToLobbyLeave replay playback for this connection only. Other viewers stay in the replay; the room resets to a clean lobby only after the last viewer leaves. Ignored outside replay playback.
pingts: numberLatency probe; server replies with pong.
netReportreport: ClientNetReportPeriodic client-observed network/render health aggregate. Server logs notable reports for diagnostics only; it never affects simulation state.
setRoomTimeSpeedspeed: f32Set the room-controlled time speed where the current room clock capability allows speed control. 0 pauses replay playback and dev scenario watch rooms; other accepted speeds are clamped. Ignored in fixed-realtime rooms.
stepRoomTimeAdvance room-controlled time by one authoritative simulation tick where the current room clock capability allows stepping. Currently accepted only in paused dev scenario watch rooms.
seekRoomTimeticksBack: u32Rewind room-controlled time by N simulation ticks where the current room clock capability allows relative seek; pass a large value (e.g. 2^31-1) to reset to tick 0. Currently accepted only in replay rooms.
seekRoomTimeTotick: u32Seek room-controlled time to an absolute simulation tick where the current room clock capability allows absolute seek. Replay rooms clamp to duration, rate-limit accepted seeks, restore the nearest recorded replay keyframe at or before the target tick, fast-forward the remaining ticks, re-send start, and emit roomTimeState. Replay rooms record authoritative keyframes every 2,000 ticks while playback/seek fast-forwarding advances.
setReplayVisionvision: ReplayVisionRequestSelect replay fog/vision for this viewer only. Ignored outside replay rooms. The server validates the request and applies it to that viewer’s subsequent snapshot projection.
labrequestId: u32, op: LabClientOpPrivileged lab request envelope. requestId must be nonzero. Ignored before join; rejected outside lab rooms and from non-operators with labResult. Accepted setup mutations, issue-as commands, and vision changes are room-local and append to the lab operation log.
requestReplayBranchRequest creation of a new practice branch room from this replay room’s current authoritative server tick. Ignored before join; rejected outside replay playback. The server rejects replays with AI seats in the first implementation and returns error. On success, the source replay room broadcasts replayBranchCreated to all current viewers.
claimBranchSeatplayerId: u32Claim one original replay player seat in a replay branch staging room. Ignored outside branch staging. Rejected with error if the seat is unknown, already claimed, or this occupant already claimed another seat.
releaseBranchSeatplayerId: u32Release one original replay player seat currently claimed by this occupant in branch staging. Ignored outside branch staging or when the occupant does not own that claim.
startBranchHost asks to launch the staged replay branch. Ignored outside branch staging and from non-hosts. The server rejects launch until every original active seat is claimed; live promotion is handled by the branch promotion phase.
selectMapmap: stringHost selects the lobby map by its stable map name. Ignored outside the lobby, from non-hosts, during match countdown, or in dev-watch rooms. The server broadcasts the selected value as lobby map and the available catalog as maps[].

Live player command messages MUST include clientSeq; unsequenced live commands are protocol-invalid and are not executed. The browser resets allocation to 1 on every start payload and increments monotonically for every gameplay command sent through the live transport. 0 is reserved/invalid. The sequence range does not wrap within a match; exhausting u32 ends client command allocation for that match rather than reusing earlier ids. clientSeq belongs to the transport envelope only and is intentionally absent from replay/simulation command DTOs.

Command (the cmd object) — c is the command discriminator:

cFieldsMeaning
moveunits: u32[], x: f32, y: f32, queued?: boolMove selected units to a world point. Infantry ignore enemies until they arrive or receive another order; tanks and scout cars keep driving and fire at in-range enemies without chasing. When queued is true, store future movement intent instead of replacing the active order.
attackMoveunits: u32[], x: f32, y: f32, queued?: boolMove while attacking enemies encountered; this is the aggressive movement order. When queued is true, store future attack-move intent instead of replacing the active order.
attackunits: u32[], target: u32, queued?: boolAttack a specific entity. When queued is true, store future attack intent instead of replacing the active order.
deconstructunits: u32[], target: u32, queued?: boolSend one selected worker to deconstruct a completed Tank Trap. The target may be friendly, allied, or enemy; enemy traps must be visible when the command is accepted or when a queued stage promotes. Deconstruction uses the Tank Trap’s 10-second build time, cannot be sped up by multiple workers on the same trap, and refunds the Tank Trap cost to the deconstructing player’s economy. When queued is true, store one future deconstruct intent using the same selected-worker allocation policy as build orders.
setupAntiTankGunsunits: u32[], x: f32, y: f32, queued?: boolManually emplace owned anti-tank guns and artillery toward a world point. When queued is true, append a future setup-facing intent for owned completed Anti-Tank Guns and artillery only; the stored point is evaluated from the unit’s position when the stage promotes. Immediate setup clears movement/target state, records the setup facing, and enters setting_up. Other selected units are ignored.
tearDownAntiTankGunsunits: u32[]Pack up owned anti-tank guns that are setting_up or deployed. Other selected units are ignored.
chargeunits: u32[]Legacy Rifleman Charge activation. Preserved for old clients/replays, but no longer has eligible carriers.
useAbility`ability: “charge”“smoke”
recastAbilityability: "ekatTeleport", units: u32[], targetObjectId?: u32, queued?: boolExplicit second activation for an existing per-caster ability state. The server does not infer recast from missing x/y; it validates ownership, live caster eligibility, matching active return marker state, the no-instant-return availability tick, and destination standability, then returns Ekat to the marker and consumes it.
setAutocastability: "mortarFire", units: u32[], enabled: boolToggle server-authoritative autocast for owned Mortar Teams. Other unit/ability combinations are ignored.
gatherunits: u32[], node: u32, queued?: boolSend workers to harvest a resource node. When queued is true, store future gather intent instead of replacing the active order.
buildunits: u32[], building: string, tileX: u32, tileY: u32, queued?: boolSelected workers construct a building at a tile. The server allocates one compatible worker per build click, first walks that worker to a nearby point outside the requested footprint, then starts construction once it is in range. building ∈ building kinds. When queued is true, store future build intent instead of replacing the active order.
trainbuilding: u32, unit: stringQueue a unit at a production building.
researchbuilding: u32, upgrade: stringQueue a permanent player upgrade at a tech building. Upgrade ids: methamphetamines at the Training Centre; anti_tank_gun_unlock, artillery_unlock, tank_unlock, command_car_unlock, and mortar_autocast at the R&D Complex (research_complex). artillery_unlock requires completed anti_tank_gun_unlock; command_car_unlock requires completed tank_unlock.
cancelbuilding: u32Cancel the latest item in a building’s production queue.
stopunits: u32[]Clear orders and return selected units to ordinary idle behavior.
holdPositionunits: u32[]Clear active and queued unit orders, then stand ground. Held units do not chase or path to auto-acquire enemies; they still fire at enemies already in weapon range and can still be pushed by collision resolution.
setRallybuilding: u32, x: f32, y: f32, `kind?: “move”“attackMove”, queued?: bool`

Servers MUST ignore commands referencing entities the player does not own, unknown ids, illegal placements, or unaffordable actions (fail silently or emit a notice event). For appendable unit commands, omitted queued is equivalent to false. Unit order queues are capped at 8 intents per unit. Queued intents are lightweight future intent only; active Order remains the per-tick execution state. Non-queued unit orders replace the active order and clear future unit intents; stop and holdPosition clear both active and queued unit orders. Production building rally plans are capped at four total stages. A non-queued rally replaces the whole plan; a queued rally appends if space remains and establishes the first stage when the plan is empty.

Prediction acknowledgement has two milestones. Socket/room receipt means the server parsed a sequenced command and the room task accepted or rejected the envelope; the server may send a tiny commandReceipt containing only clientSeq, serverTick, accepted, and optional stable reason. This is diagnostics-only and is not the reconciliation acknowledgement. Sim consumption means the authoritative tick stream drained the queued command into the simulation; snapshots expose only this milestone. Sim consumption does not mean the command succeeded: ownership, affordability, visibility, placement, and other authoritative validation can still make the command a no-op.

ClientNetReport is an untrusted, rate-limited diagnostic aggregate emitted by the browser while in a match:

{
  schemaVersion: u8,        // currently 1
  matchRunId: string,       // live match correlation id from start payload; empty when absent
  elapsedMs: u32,           // client-side aggregation window duration
  matchTick: u32,           // latest snapshot tick observed by this client
  rttMs: u16,               // latest app-level ping round-trip sample
  rttMaxMs: u16,            // max round-trip sample in this report window
  badRttSamples: u32,       // samples at/above the client's latency warning threshold
  snapshotJitterMs: u16,    // current max receive jitter over the client's short jitter window
  snapshotGapMaxMs: u16,    // largest observed interval between received snapshots
  jitterSamples: u32,       // jitter incidents in this report window
  snapshots: u32,           // snapshots received in this report window
  snapshotBytesTotal: u32,   // total received snapshot application payload bytes
  snapshotBytesMax: u32,     // largest received snapshot application payload bytes
  snapshotBytesAvg: u32,     // average received snapshot application payload bytes
  snapshotMessageCount: u32, // snapshot frames observed by the transport report window
  snapshotByteSource: string, // currently "messagepack-application-payload"; not compressed wire bytes
  snapshotCodec: string,      // currently "messagepack-compact"
  snapshotCodecVersion: u16,  // currently 1
  snapshotFrameKind: string,  // currently "binary"
  snapshotBytesP95: u32,     // bucketed p95 received snapshot application payload bytes
  snapshotSegmentBudgetBytes: u32, // payload-byte single-segment budget used by this client
  snapshotOverSegmentBudgetCount: u32, // snapshot frames above snapshotSegmentBudgetBytes
  snapshotOverSegmentBudgetPctX100: u16, // over-budget percentage multiplied by 100
  snapshotParseMaxMs: u16,   // max browser frame parse cost for snapshot frames
  snapshotParseP95Ms: u16,   // bucketed p95 frame parse cost for snapshot frames
  snapshotDecodeMaxMs: u16,  // max compact protocol decode cost
  snapshotDecodeP95Ms: u16,  // bucketed p95 compact protocol decode cost
  websocketExtensions: string, // bounded browser WebSocket.extensions after open
  websocketCompression: string, // normalized "permessage-deflate" or "none"
  snapshotApplyMaxMs: u16,   // max GameState.applySnapshot cost
  snapshotApplyP95Ms: u16,   // bucketed p95 GameState.applySnapshot cost
  predictionApplyMaxMs: u16, // max authoritative prediction reconciliation/overlay cost
  predictionApplyP95Ms: u16, // bucketed p95 authoritative prediction reconciliation/overlay cost
  snapshotTickGapMax: u32,   // largest authoritative tick delta between received snapshots
  staleSnapshotCount: u32,   // snapshots older than the latest accepted snapshot tick
  duplicateSnapshotCount: u32, // snapshots with the same tick as the latest accepted snapshot
  skippedSnapshotCount: u32, // snapshots whose tick jumped by more than one
  snapshotBurstCount: u32,   // frames where more than one snapshot arrived before the next rAF
  snapshotBurstMax: u32,     // max snapshots received before one rAF boundary
  frameGapMaxMs: u16,       // largest requestAnimationFrame gap in this report window
  fpsEstimate: u16,         // coarse average client frame rate for this report window
  frameWorkMaxMs: u16,      // largest measured JS frame work duration
  frameWorkP95Ms: u16,      // bucketed p95 measured JS frame work duration
  slowFrameCount: u32,      // frames whose gap or work crossed the slow-frame threshold
  worstFramePhase: string,  // bounded profiler label most often worst in this report window
  worstFramePhaseMs: u16,   // max duration for worstFramePhase
  rendererMaxMs: u16,       // largest measured match.renderer duration
  rendererP95Ms: u16,       // bucketed p95 match.renderer duration
  entityCount: u32,         // latest client-visible entity count context
  selectedCount: u16,       // latest local selection size context
  visibleTileCount: u32,    // latest visible-tile count context
  viewportWidth: u16,       // latest CSS viewport width context
  viewportHeight: u16,      // latest CSS viewport height context
  devicePixelRatioX100: u16, // latest devicePixelRatio multiplied by 100
  hidden: bool,             // document.hidden when the report was sent
  focused: bool,            // document.hasFocus() when available
  wsBufferedBytes: u32,     // browser WebSocket bufferedAmount
  serverTickMs: u16,        // latest server tick work duration seen in snapshot netStatus
  serverLagMs: u16,         // latest scheduler lag seen in snapshot netStatus
  slowTickCount: u32,       // latest server slow-tick count seen by this client
  headOfLineCount: u32,     // latest per-client pending-snapshot replacement count seen
  predictionMode: string,   // disabled, tracking, predicting, or resyncing
  pendingCommandCount: u16,
  acknowledgedCommandLatencyMs: u16, // latest local issue -> sim-ack latency
  commandsIssued: u32,                 // commands allocated in this report window
  commandSocketSendAccepted: u32,      // WebSocket.send accepted by the browser
  commandServerReceived: u32,          // accepted commandReceipt count
  commandSimAcknowledged: u32,         // commands covered by snapshot sim-consumption ack
  commandRejected: u32,                // rejected commandReceipt count
  commandIssueToServerReceiptLatestMs: u16,
  commandIssueToServerReceiptMaxMs: u16,
  commandIssueToServerReceiptP95Ms: u16,
  commandServerReceiptToSimAckLatestMs: u16,
  commandServerReceiptToSimAckMaxMs: u16,
  commandServerReceiptToSimAckP95Ms: u16,
  commandIssueToSimAckLatestMs: u16,
  commandIssueToSimAckMaxMs: u16,
  commandIssueToSimAckP95Ms: u16,
  commandAckSnapshotReceivedToAppliedLatestMs: u16,
  commandAckSnapshotReceivedToAppliedMaxMs: u16,
  commandAckSnapshotReceivedToAppliedP95Ms: u16,
  oldestPendingCommandAgeMs: u16,
  maxPendingCommandCount: u16,
  correctionDistancePx: u16,         // largest correction observed by the client
  correctionCount: u32,
  predictionDisableCount: u32,
  wasmTickMs: u16,          // latest measured WASM prediction/replay work duration
  wasmMemoryBytes: u32,     // current WASM memory buffer size, when available
  predictionReplayTicks: u16 // latest local replay/advance ticks processed in one measured step
}

The snapshot payload, codec, parse, decode, apply, prediction-apply, cadence, and command milestone fields are report-window aggregates only; raw snapshot payloads, raw timestamp arrays, entity ids, unit ids, target ids, positions, replay data, and command payloads are not uploaded. The canonical single-segment payload budget is 1280 bytes. It is intentionally below a common 1460-byte Ethernet TCP MSS because the measured snapshot bytes are only WebSocket application payload bytes and exclude WebSocket framing plus TLS, TCP, and IP overhead. Command milestone timing splits local issue to receipt, receipt to sim acknowledgement, issue to sim acknowledgement, and ack snapshot receipt to browser apply. The frame-work and renderer fields come from the browser’s bounded frame-profiler report window; the local debug surface may keep richer cumulative phase tables, but those raw arrays and detailed recent frames are not uploaded. The server logs this message only when the aggregate contains notable lag, jitter, browser frame stalls, local JS frame work, large-payload pressure, packet-budget pressure, snapshot parse/decode/apply cost, snapshot cadence/burst issues, renderer cost, WebSocket backlog, server tick/scheduler pressure, command milestone delay/rejection, or prediction correction/fallback signals, alongside the connection’s player_id, room name, and reported match_run_id. Values are advisory because clients are untrusted; use them to diagnose transport/browser/prediction/render behavior, not as gameplay authority.

2.2 Server → Client (ServerMessage)

tFields
welcomeplayerId: u32 — assigned on connect.
lobbyroom: string, hostId: u32, players: LobbyPlayer[], canStart: bool, quickstart: bool, teamPreset: string, map: string, maps: AvailableMap[]
matchCountdowndurationMs: u32, words: string[] — reliable pre-match countdown sent to every lobby participant after the host starts and before start. During this interval the server keeps the room in lobby setup, disables canStart, freezes lobby edits, rejects new joins, and sends start only after the countdown duration elapses.
startGame start payload (see 2.3).
snapshotPer-player snapshot (see 2.4).
roomTimeStateRoom-controlled time state (see 2.6).
livePauseStateLive match pause state (see 2.6).
replayAnalysisObserver analysis state (see 2.7).
joinReplayPromptroom: string — the requested room is currently replay playback; clients should confirm before retrying join with replayOk: true.
replayBranchCreatedbranchRoom: string, sourceTick: u32, seats: ReplayBranchSeat[] — a separate practice branch room has been created from the source replay’s current authoritative tick.
branchStagingroom: string, sourceTick: u32, hostId: u32, seats: BranchStagingSeat[], occupants: BranchStagingOccupant[], canStart: bool — reliable current state for a replay branch staging room. Sent after joins, leaves, claims, and releases.
shutdownWarningdeadlineUnixMs: u64, secondsRemaining: u64 — deploy/termination drain has started; active matches may continue until the deadline, but new match starts are disabled.
gameOver`winnerId: u32
pongts: number (echo of the ping ts)
commandReceiptclientSeq: u32, serverTick: u32, accepted: bool, reason?: string — reliable diagnostics-only room receipt. Does not reconcile prediction.
errormsg: string

LobbyPlayer: { id: u32, teamId: u32, factionId: string, name: string, ready: bool, color: string, isAi: bool, aiProfileId?: string, isSpectator: bool }. isAi is true for computer opponents (always shown ready; the client renders an “AI” tag, a host-only profile selector, and a host-only remove control instead of a ready toggle). aiProfileId is present only for computer opponents and identifies the live AI profile that seat will use when the match starts. isSpectator is true for human observers; they do not consume active map starts, block readiness, or count toward win/loss.

AvailableMap: { name: string, description: string }. name is the stable value sent back in selectMap; description is display text for the lobby selector. Lobby map is the current selected map name and is distinct from replay start metadata mapName.

teamId is nonzero for active match players and AI seats. New active players and default-added AI opponents are assigned to the next empty team after the currently occupied teams when possible, falling back to the first empty team in 1..=4; the host may move active human or AI seats between those team slots. The normal lobby UI shows occupied teams plus one “New team” drop target while fewer than four teams are occupied, plus a bottom spectator drop target for host-managed observer moves. Spectator lobby rows carry teamId: 0 because they are not match players. canStart is false until there is at least one active seat, every active human is ready, every active seat has a team in 1..=4, and the active seat count is at or below the four-player map-start cap.

PlayerScore: { id: u32, teamId: u32, name: string, color: string, unitScore: u32, structureScore: u32, unitsKilled: u32, unitsLost: u32, buildingsKilled: u32, buildingsLost: u32 }. scores is a frozen server snapshot taken when that recipient gets gameOver; it is not live-updated while a 3-4 player match continues. Unit/structure score is the configured steel+oil value of every unit/building entity created for that player, including starting entities.

winnerTeamId is the winning team’s id when a winner exists, otherwise null. winnerId remains for FFA compatibility. During singleton-team FFA, winnerTeamId matches winnerId; during team wins, winnerId is the first living player on the winning team in stable start/lobby order.

ReplayBranchSeat: { playerId: u32, teamId: u32, factionId: string, name: string, color: string, claimable: bool }. Seats are listed in original replay player order. claimable is false only for unsupported original seats; the first implementation rejects AI-seat replays before creating a branch, so successful branch creation currently reports all seats as claimable.

BranchStagingSeat: { playerId: u32, teamId: u32, factionId: string, name: string, color: string, claimantId?: u32, claimantName?: string }. Seats are listed in original replay player order. A missing claimant means that original seat is still available to claim.

BranchStagingOccupant: { id: u32, name: string }. Occupants are all human viewers currently in the branch staging room, whether they have claimed an original seat or are remaining spectators.

2.3 start payload

Sent once when the match begins. Carries everything static for the whole match.

{
  t: "start",
  playerId: u32,                 // your id (repeat of welcome for convenience)
  spectator: bool,               // true when this connection is observing only
  predictionBuildId?: string,    // live active players only; server/client bundle id
  predictionVersion?: u32,       // live active players only; currently 1
  matchRunId?: string,           // live match correlation id for log joins
  capabilities?: {               // explicit recipient-scoped shared room affordances
    roomTime?: {
      available?: bool,
      setSpeed?: bool,
      pause?: bool,
      step?: bool,
      seekRelative?: bool,
      seekAbsolute?: bool,
      timeline?: bool
    },
    matchControls?: { pause?: bool },
    visibility?: { replayVision?: bool },
    commands?: { gameplay?: bool }
  },
  diagnostics?: {                // explicit recipient-scoped diagnostic affordances
    movementPaths?: "ownerOnly"|"all",
    observerAnalysis?: bool
  },
  replay?: {                     // present for production replay playback
    artifactSchemaVersion: u32,
    serverBuildSha: string,
    mapName: string,
    mapSchemaVersion: u32,
    mapContentHash: string,
    seed: u32,
    durationTicks: u32
  },
  lab?: {                        // present for lab room starts
    room: string,                // safe public lab id, not the hidden internal room prefix
    operatorId: u32,
    role: "operator"|"readOnly",
    vision: { mode: "fullWorld" } | { mode: "team", teamId: u32 } | { mode: "teams", teamIds: u32[] },
    dirty: bool,
    operationCount: u32
  },
  tick: u32,                     // starting tick (usually 0)
  map: {
    width: u32, height: u32,     // in tiles
    tileSize: u32,               // world px per tile
    // terrain: row-major array length width*height, each a TerrainKind code (u8).
    terrain: number[],
    // All neutral resource nodes (static, never move). Sent so the client can
    // render them on the minimap before fog-of-war reveals them.
    resources: [ { id: u32, kind: "steel"|"oil", x: f32, y: f32 } ]
  },
  players: [ { id, teamId, factionId, name, color, startTileX, startTileY } ], // active match players only
}

Units/buildings arrive via snapshots (so they obey fog), including the player’s own starting City Centre + workers. When the lobby’s setQuickstart toggle is enabled, every player starts with 99,999 steel and 99,999 oil instead of the default opening resources, and each human player also starts with five supply depots, one Gun Works (steelworks kind), one R&D Complex (research_complex kind), one Training Centre, two Barracks, two Vehicle Works (factory kind), and five of each unit kind including Command Cars. Debug mode also adds one inert enemy player in the clockwise-adjacent corner from the first human start, with five deployed Mortar Teams clumped around one Scout Car and four enemy Supply Depots five tiles north/east/south/west of the clump. It also sets diagnostics.movementPaths: "ownerOnly" for active players, which lets the client expose local movement-waypoint overlay controls for the owner-only debugPath fields in snapshots. Spectators do not receive that movement-path diagnostic affordance. Dev scenario start payloads may advertise diagnostics.movementPaths: "all" because those rooms intentionally use full-world diagnostic projection. Replay viewers and live spectators receive diagnostics.observerAnalysis: true only when room projection policy will send observer-analysis payloads to that recipient. capabilities is the neutral control/affordance contract. Live active players receive commands.gameplay: true and matchControls.pause: true; spectators, replay viewers, dev-watch viewers, and lab viewers do not. Replay branch live players also receive pause capability only when their connection is mapped to an original active seat through the branch-live seat alias path. Replay playback advertises room-time speed/pause/relative seek/absolute seek/timeline controls plus visibility.replayVision: true. Dev scenario watch rooms advertise speed/pause/step room-time controls without replay seek or replay-vision controls. Clients must not infer these shared affordances from replay, lab, URL-local dev-watch state, or legacy debug flags. Spectator start payloads keep the spectator connection’s playerId, set spectator: true, and list only active match players in players.

Lab room start payloads set lab metadata and currently also set spectator: true with prediction metadata omitted. Labs use a hidden internal room id, a default two-team real Game template, and server-owned projection. role names the room-owned operator/read-only viewer classification; only the operator may send privileged lab operations in the MVP.

For compatibility with hand-built fixtures and older replay artifacts, missing teamId values at simulation/replay/test-helper boundaries default to singleton FFA: the player’s own nonzero id. Current live server payloads always emit explicit nonzero teamId values for active players. The canonical default faction id is kriegsia; ekat is also a playable catalog id. Start payloads emit factionId for every active start player, lobby seat, and replay branch seat, and replay artifacts store faction_id for every player. Missing faction requests default to kriegsia in normal lobby, AI, quickstart, self-play, and dev-start contexts, while explicit kriegsia and ekat requests are accepted by the current playable faction policy. Other ids are rejected unless a lifecycle path explicitly accepts recorded replay data or the phase2_empty_fixture test fixture. Protocol vocabulary is not lifecycle admission: adding a string constant, compact code, or payload field does not make a faction playable. Fixture-only, reserved/future, and historical-only ids must not become valid setFaction, AI-seat, replay-branch, or post-match replay ids without updating docs/design/faction-architecture-inventory.md, the lifecycle validator, and protocol parity in the same change.

Prediction start compatibility metadata is present only for live active players. Clients MUST keep prediction disabled unless predictionVersion matches their supported prediction protocol version and, when both sides know a build id, predictionBuildId matches the client bundle id. Mismatches fall back to authoritative snapshots/tracking instead of running local visual reconciliation.

Replay start payloads include replay metadata so the client can display or cache a self-describing playback session. The server validates replay artifacts before playback: artifact schema version, server build SHA, map name, map schema version, and map content hash must match the running server/map asset or the replay is rejected with a clear error. Saved self-play artifacts use the same ReplayArtifactV1 contract as post-match and match-history replays; pre-unified dev-only artifact payloads are rejected instead of falling back to a separate loader.

Replay artifact schema version 2 stores ordered players[] with each original team_id and required faction_id, plus playerLoadouts[] with one { playerId, factionId, loadoutId, startingSteel, startingOil } record per player. Replay reconstruction uses those per-player loadout records instead of one global startingSteel/startingOil/startingLoadoutMode shim. The compatibility winnerId, optional winnerTeamId, and finalScores[] with each row’s teamId remain part of the artifact. Missing players[].faction_id, missing/mismatched playerLoadouts[], or artifact schema version 1 payloads are rejected. Missing players[].team_id, finalScores[].teamId, or winnerTeamId in older singleton-FFA-compatible schema-2 fixtures defaults through the documented singleton team behavior; new captures always include explicit nonzero player and score team ids, required player faction ids, required player loadout records, and winnerTeamId when there is a winning team.

When a real multi-player match ends, the server sends the normal gameOver score payload, clears pending latest-only live snapshots for connected humans, and then sends a replay start payload at tick 0 plus roomTimeState. Post-match replay defaults every viewer to all active players’ combined authoritative vision and starts at 2.0x speed. returnToLobby detaches only the requesting replay viewer; the shared replay session remains alive for everyone else. The room drops the replay simulation and resets to a clean lobby only after the last viewer leaves. Dedicated replay rooms created for match-history or dev replay viewing follow the same per-viewer detach rule; they keep the shared replay session alive until the room empties.

2.4 snapshot payload (per-player, fog-filtered)

Snapshot remains the semantic shape used by server game code and by client modules after transport decode:

{
  t: "snapshot",
  tick: u32,
  steel: u32, oil: u32,       // your resources
  supplyUsed: u32, supplyCap: u32,
  entities: Entity[],            // your non-resource entities (always) + entities visible to living-team current/death vision
  resourceDeltas?: ResourceDelta[], // visible resource remaining updates; omitted when empty
  smokes?: SmokeCloud[],         // active smoke clouds visible to this recipient; omitted when empty
  abilityObjects?: AbilityObject[], // active ability world objects visible to this recipient; omitted when empty
  visibleTiles?: u8[],           // row-major current server visibility; 1 = visible, 0 = fogged
  rememberedBuildings?: RememberedBuilding[], // recipient-only stale enemy building intel
  events: Event[],               // transient things to surface (see 2.5)
  upgrades?: string[],           // completed permanent upgrades for this recipient
  playerResources?: {id, steel, oil, supplyUsed, supplyCap}[], // all players; spectator/replay mode only
  netStatus: {                // per-recipient server-side health for the current match
    serverLagMs: u16,         // how late this room started the tick vs its scheduled time
    tickMs: u16,              // elapsed room-tick work so far when this snapshot was built
    slowTick: bool,           // true when the room was at/over its tick budget this tick
    slowTickCount: u32,       // number of slow-tick incidents so far this match
    headOfLine: bool,         // true when an older unsent snapshot was still pending for this client
    headOfLineCount: u32,     // number of pending-snapshot replacements so far this match
    predictionVersion?: u32,  // live active players only; currently 1
    lastSimConsumedClientSeq?: u32,  // highest contiguous local clientSeq consumed by the sim
    lastSimConsumedClientTick?: u32 | null // authoritative tick that consumed that sequence
  }
}

Steel, Oil, and Supply are fixed protocol fields for this faction plan. Normal snapshots, spectator/replay playerResources, compact "s", compact "pr", start-map resources, score values, and observer analysis remain on the current Steel/Oil/Supply schema; faction-specific or arbitrary resource vectors are deferred to a separate generic-resource migration.

For normal active-player snapshots, entity visibility and visibleTiles are projected from the server-authoritative union of current fog grids contributed by living teammates on the recipient’s team. A defeated/disconnected teammate stops contributing live sight; if that player’s team still has a living member, their own connection continues to receive the surviving team’s current visibility. Allied non-resource entities visible through team current fog expose full read-only inspection details: hp/state/facing/setup state, production or research kind/progress/queue length, construction progress, worker latched node, active Breakthrough status, and safe combat tracers. Combat targetId and weaponFacing for allied units are sent only when the target is visible in the recipient’s team-current actionable fog, so allied units attacking hidden enemies do not reveal hidden target ids or target directions. steel, oil, supply, upgrades, rallies, order plans, construction activity hints, ability controls/autocast toggles, debug paths, and command authority remain exact-owner-only. Snapshot-only lingering death sight may make non-owned units/buildings visible as visionOnly; those views are visual intel only and do not refresh remembered buildings or validate targeted commands. Ability world objects are projected separately in abilityObjects: normal players receive only objects whose world position is visible in their current team fog, while full-world dev snapshots include every object and spectator/replay snapshots use the existing union vision. Enemy objects never carry owner-only state, and sourceCasterId is omitted unless the caster is safe for the recipient or the recipient is an owner/spectator/full-world viewer.

MessagePack compact binary snapshot frames are the live WebSocket snapshot path. Each binary frame starts with the ASCII magic RTSM, a one-byte snapshot codec version (1), then a MessagePack map containing the same compact snapshot object shape shown below. The active snapshot codec is messagepack-compact, codec version 1, compact snapshot version 22. client/src/net.js parses the binary frame into the raw compact snapshot object, then decodeCompactSnapshot expands it back into the semantic object above before dispatching S.SNAPSHOT.

The rollout is direct and latest-version-only. Reliable non-snapshot messages (welcome, start, lobby, pong, errors, room/lab/replay control messages, and game over) remain JSON text. The server does not negotiate stale-client capability and does not maintain a compact JSON fallback mode for live snapshots; rollback is a normal Git revert of the MessagePack snapshot change. Compact JSON serialization remains available in local tooling and tests as a historical size baseline, and the browser can still decode object-shaped JSON snapshots for narrow dev/test use, but that is not the normal live path.

The live compression diagnostics are report-only. snapshotBytes* fields are browser-delivered MessagePack application payload measurements after any transport extension would have been decoded by the browser; they are not compressed wire bytes. snapshotCodec, snapshotCodecVersion, and snapshotFrameKind identify the active snapshot path for the report window. websocketExtensions mirrors the bounded browser WebSocket.extensions string, and websocketCompression is a normalized label that is permessage-deflate only when that extension appears. With the current Axum 0.8 / Tungstenite 0.29 server stack, direct permessage-deflate negotiation is not available, so the expected live label is none until a future phase changes the WebSocket implementation or adds an explicit application compression envelope.

{
  "t": "snapshot",
  "v": 22,
  "s": [tick, steel, oil, supplyUsed, supplyCap],
  "e": [
    [
      id, owner, kind, x, y, hp, maxHp, state,
      facing?, weaponFacing?, prodKind?, prodProgress?, prodQueue?,
      buildProgress?, latchedNode?, targetId?, setupState?, remaining?, rally?, oilUsed?,
      setupFacing?, orderPlan?, chargeCooldownLeft?, abilities?, breakthroughTicks?,
      visionOnly?, debugPath?, rallyPlan?, prodUpgrade?, buildActive?
    ]
  ],
  "r": [[id, remaining]],         // omitted when empty
  "sm": [[id, x, y, radiusTiles, expiresIn]], // omitted when empty
  "ao": [[id, owner, ability, kind, x, y, expiresIn?, sourceCasterId?, ownerState?]], // abilityObjects; omitted when empty
  "fg": [firstValue, runLen, ...], // RLE visibleTiles; omitted when empty/no-fog
  "mb": [[id, owner, kind, x, y, [[tileX, tileY], ...], observedTick]], // rememberedBuildings; omitted when empty
  "ev": [EventRecord],            // omitted when empty
  "pr": [[id, steel, oil, supplyUsed, supplyCap]], // omitted in normal play; present in spectator/replay
  "n": [serverLagMs, tickMs, flags, slowTickCount, headOfLineCount,
        predictionVersion?, lastSimConsumedClientSeq?, lastSimConsumedClientTick?]
}

Compact numeric codes:

VocabularyCodes
kind1 worker, 2 rifleman, 3 machine_gunner, 4 anti_tank_gun, 5 tank, 6 city_centre, 7 depot, 8 barracks, 9 training_centre, 10 factory, 11 steel, 12 oil, 13 steelworks, 14 scout_car, 15 mortar_team, 16 artillery, 17 research_complex, 18 command_car, 19 ekat, 20 zamok, 21 tank_trap
state1 idle, 2 move, 3 attack, 4 gather, 5 build, 6 train, 7 construct, 8 dead
setupState1 packed, 2 setting_up, 3 deployed, 4 tearing_down
orderStage1 move, 2 attackMove, 3 attack, 4 gather, 5 build, 6 smoke, 7 setupAntiTankGuns, 8 charge, 9 mortarFire, 10 pointFire, 11 breakthrough, 12 ekatTeleport, 13 ekatLineShot, 14 ekatMagicAnchor, 15 deconstruct
ability1 charge, 2 smoke, 3 mortarFire, 4 pointFire, 5 breakthrough, 6 ekatTeleport, 7 ekatLineShot, 8 ekatMagicAnchor
abilityObject.kind1 returnMarker, 2 magicAnchor, 3 lineProjectile
upgrade1 methamphetamines, 2 anti_tank_gun_unlock, 3 tank_unlock, 4 artillery_unlock, 5 mortar_autocast, 6 command_car_unlock
notice.severity1 info, 2 warn, 3 alert
EventRecord[1, from, to] attack, [1, from, to, reveal?, toPos?] attack with optional shooter reveal and target position, [2, id, x, y, kind] death, [3, id, kind] build, [4, msg] notice, [4, msg, severity] position-free notice with severity, [4, msg, severity, x, y] positioned notice, [5, [fromX, fromY], [toX, toY], delayTicks] smoke launch, [6, x, y, radiusTiles] mortar impact/marker, [6, x, y, radiusTiles, from?, reveal?] mortar impact with optional shooter reveal, [7, from, [x, y], radiusTiles, delayTicks] artillery target marker, [8, x, y, radiusTiles] artillery impact, [9, from, [fromX, fromY], [toX, toY], radiusTiles, delayTicks] mortar launch, [10, to] overpenetration damage

2.4.1 Boundary inventory

Phase 1 records the current source-of-truth map before later phases tighten enforcement. This is an inventory only; it does not change the wire shape or compact snapshot version.

Value/pathRust ownerJS mirror pathCategoryCurrent checkerProposed future checkerClient-only exclusion reasonCompact version impact
ClientMessage, ServerMessage, Command, lobby/replay/branch message tags and fieldsserver/crates/protocol/src/lib.rsclient/src/protocol.js C, S, CMD, msg.*, decodeServerMessagewire DTOtests/protocol_parity.mjs compares the structured Rust protocol contract dump to JS tags/builders/decoder; serde compile/tests cover Rust shapeRemaining source-text checks for DTO/lobby assertions outside the current dump scopeNone; JS is a protocol mirrorNo compact bump unless a compact snapshot slot/code changes; normal JSON message changes still require Rust, JS, and docs together
Semantic start/snapshot/replay/analysis DTOsserver/crates/contract/src/lib.rs, re-exported by server/crates/protocol/src/lib.rsclient/src/protocol.js decoder output consumed by client moduleswire DTOtests/protocol_parity.mjs fixture decodes selected compact fields; Rust serde tests cover local serializationStructured contract/schema dump for semantic DTO fields plus compact round-trip fixturesNone; JS is a protocol mirrorCompact bump only when the live compact representation changes
terrain codesserver/crates/protocol/src/lib.rs terrain; adapter test checks rules terrain constantsclient/src/protocol.js TERRAIN and PASSABLEwire DTO / compact transport codetests/protocol_parity.mjs extracts Rust terrain codesStructured protocol constants dumpNoneNo compact snapshot bump today; terrain is in the start.map.terrain payload, not the compact snapshot frame
kinds strings, KIND, UNIT_KINDS, BUILDING_KINDS, RESOURCE_KINDSserver/crates/protocol/src/lib.rs kinds; domain identity is rts-rules::EntityKind::stable_id()client/src/protocol.js KIND, UNIT_KINDS, BUILDING_KINDS, RESOURCE_KINDSwire DTO plus domain adapter groupingtests/protocol_parity.mjs checks kind code mapping; adapter tests round-trip every EntityKind; catalog parity checks many kind referencesStructured protocol constants dump plus catalog export that classifies unit/building/resource groupsNoneBump only if compact kind codes or compact slots change; append-only codes otherwise
server/src/protocol.rs and server/crates/sim/src/protocol.rs kind conversionRules/sim-aware adapter modulesNo direct JS mirror beyond the protocol kind stringsdomain adapter mappingRust adapter tests in both modulesOne shared rules-aware adapter path with a single round-trip testNot client dataNo compact impact unless output kind strings/codes change
states, SETUP, NOTICE_SEVERITY, REPLAY_VISION, and event discriminatorsserver/crates/protocol/src/lib.rs string vocabulary and event serializationclient/src/protocol.js constants and decoderwire DTO / compact transport codetests/protocol_parity.mjs checks state, setup, notice severity, and event compact codes; selected decoder fixturesStructured protocol constants and compact event-shape dumpNoneBump when compact event/entity slots change
COMPACT_SNAPSHOT_VERSION, PREDICTION_PROTOCOL_VERSION, compact top-level keys, optional entity slots, limits, and net status slotsserver/crates/protocol/src/lib.rs compact serializerclient/src/protocol.js COMPACT_SNAPSHOT_VERSION, decode helpers, MAX_COMPACT_* limitscompact transport codetests/protocol_parity.mjs source-text version checks and fixture decodeStructured compact schema dump including slot names, order, caps, and versionNoneDirect owner of compact version; slot/order changes require bump unless strictly optional trailing additions preserve decoder compatibility by explicit decision
Compact code tables for kind, state, setup, order stage, ability, ability object kind, upgrade, notice severity, and event recordsserver/crates/protocol/src/lib.rs code functions and event serializerclient/src/protocol.js *_CODE mapscompact transport codetests/protocol_parity.mjs extracts Rust functions/events and rejects duplicate or 255 real codesStructured protocol constants dump generated from Rust instead of source scrapingNone255 remains unknown/sentinel; real codes must not use it. New codes should append without reusing old values; incompatible reorder/removal requires compact version bump
Ability and upgrade ids in command/research/snapshot payloadsProtocol string modules in server/crates/protocol/src/lib.rs; catalog facts in server/crates/rules/src/faction.rsclient/src/protocol.js ABILITY, UPGRADE, ABILITY_CODE, UPGRADE_CODE; command-card descriptors in client/src/config.jswire DTO, compact transport code, faction catalog facttests/protocol_parity.mjs checks protocol ids/codes; scripts/check-faction-catalog-parity.mjs checks catalog-exposed ability codes and descriptorsStructured protocol dump plus complete faction catalog dumpNone where mirrored from Rust; catalog descriptors are not UI-only when exported by RustCode/order changes can require compact bump; descriptor-only changes do not
DEFAULT_FACTION_IDserver/crates/contract/src/lib.rs, re-exported by protocolclient/src/protocol.jswire DTO / faction catalog facttests/protocol_parity.mjs; scripts/check-faction-catalog-parity.mjs checks default catalog idStructured contract/catalog dumpNoneNo compact impact
PLAYER_PALETTE lobby colorsserver/src/lobby/mod.rs assigns authoritative lobby/start colorsclient/src/config.js fallback paletteserver-owned presentation data mirrored by clienttests/protocol_parity.mjs source-scrapes the Rust paletteStructured lobby/config dumpNot client-only because server sends assigned colors; JS is fallback/render mirrorNo compact impact

Compact entity records are positional arrays. Optional fields keep the semantic order above and trailing missing optional fields are omitted; interior missing optional fields are encoded as null. The rally slot is itself a two-element [x, y] array (or null). The orderPlan slot is an owner-only array capped at 9 entries. It contains the current active stage first, followed by queued unit stages in execution order. Each compact stage is [kind, x, y], where kind uses the orderStage compact code table above. Stages carry safe world points only, never target ids; hidden attack target stages may be omitted rather than leaking enemy positions through fog. Production building rally points are exposed separately through rally and rallyPlan and are not part of orderPlan. rallyPlan is appended after debugPath in compact snapshots to preserve older optional slot positions; it is owner-only, capped at four stages, and uses the same [kind, x, y] compact stage encoding with move and attackMove stages. The abilities slot is owner-only and capped at 8 entries. Each compact ability cooldown is [ability, cooldownLeft, remainingUses?, autocastEnabled?, activeObjectId?, availableTick?, lockoutUntilTick?, expiresIn?], where ability uses the ability compact code table above. charge is legacy and currently has no eligible carriers. The server projects ability affordances only when the owning player’s faction catalog exposes that ability for the entity’s global kind. remainingUses is present for finite-use abilities such as Scout Car Smoke; a value of 0 means the ability is depleted and cannot be used by that caster. autocastEnabled is present for Mortar Team mortarFire so the command card can display and toggle autocast without exposing enemy data. activeObjectId, availableTick, and expiresIn are owner-only per-caster affordance fields for two-stage or persistent ability state such as Ekat’s return marker, Magic Anchor, and the active Breakthrough aura duration on the casting Command Car. lockoutUntilTick is available for owner-only ability lockouts; Magic Anchor does not currently use a destruction lockout because the anchor is not attackable. breakthroughTicks is present only while the affected visible unit has active Breakthrough speed status; it is not caster identity. Owner snapshots also expose the Command Car’s breakthrough ability cooldown and, while the caster’s aura is active, its caster-only expiresIn through abilities. visionOnly is true only for non-owned units/buildings visible through lingering death vision; clients render them below the fog overlay and must not select or issue targeted commands against them. In n.flags, bit 0 = slowTick and bit 1 = headOfLine. The optional compact n prediction fields are present only for live active player snapshots. Spectators, replay viewers, and dev full-world viewers omit prediction acknowledgement metadata. debugPath is present only when the room’s projection policy enables movement-path diagnostics for that recipient and only while the unit has remaining movement waypoints. Lobby Debug mode enables owner-only movement paths for active players. Dev scenario rooms may enable full projected movement paths. It carries { waypoints, goal, lastRepathTick, stuckTicks, staticBlockedTicks, totalWaypoints }, where waypoints are remaining {x, y} world-pixel path points in traversal order and waypoints[0] is the current movement target. The compact slot encodes this as [waypoints, goal, lastRepathTick, stuckTicks, staticBlockedTicks, totalWaypoints], with points encoded as [x, y]; waypoints is capped at 128 entries for transport.

AbilityObject: { id, owner, ability, kind, x, y, expiresIn?, sourceCasterId?, ownerState? }. The compact ao slot uses ability ids from the existing ability code table and abilityObject.kind codes from the table above. ownerState is owner/spectator/full-world data encoded as [earliestReturnTick?, hp?, radius?, destroyedLockoutTicks?, distanceTraveled?, ticksOut?]. Magic Anchor currently fills only radius; the hp and destroyed-lockout slots are retained as optional compact slots for compatibility. Normal enemy snapshots receive only the public object fields needed to render a marker at a visible position.

RememberedBuilding: { id, owner, kind, x, y, footprint, observedTick }. These records are recipient-only last-seen enemy building memory, refreshed from team-current actionable observations and sent only when the building is not currently projected as a live visible entity. They are stale intel for normal building rendering below the fog overlay and coordinate targeting context; clients must not make them selectable live entities or issue entity-targeted commands against them. footprint is an array of [tileX, tileY] cells from the last visible state. The record intentionally omits hidden live HP, current build progress, and destruction state. Artillery pointFire remains a world-coordinate ability; remembered buildings help the player know where to aim but do not become target ids.

ResourceDelta: { id: u32, remaining: u32 }. Resource node positions/kinds are static and come from start.map.resources; clients keep last-known remaining locally. The server sends remaining updates only for resource nodes currently visible to that recipient (dev full-world watch rooms receive all resource updates).

SmokeCloud: { id: u32, x: f32, y: f32, radiusTiles: f32, expiresIn: u16 }. Smoke clouds are neutral world effects, not entities. Normal player snapshots include only clouds that have at least one currently visible tile after smoke-suppressed team fog is recomputed, plus any cloud currently containing one of that player’s allied non-resource entities; spectator/dev full-world snapshots may include all active clouds. Smoke-covered enemy units/buildings, target ids, death events, and positioned notices remain fog-gated and are withheld when smoke hides the position.

Entity (lean; omit fields that don’t apply):

{
  id: u32,
  owner: u32,                    // 0 = neutral (resources), else player id
  kind: string,                  // EntityKind: "worker","rifleman","machine_gunner","anti_tank_gun","mortar_team","artillery","scout_car","tank","command_car","ekat","city_centre","zamok","depot","barracks","training_centre","research_complex","factory","steelworks"
  x: f32, y: f32,                // world px (center)
  hp: u32, maxHp: u32,
  state: string,                 // "idle","move","attack","gather","build","train","construct","dead"
  facing?: f32,                  // radians, for unit body/hull orientation (optional)
  weaponFacing?: f32,            // radians, for independent weapon/barrel orientation (optional)
  // production buildings:
  prodKind?: string,             // unit currently being produced
  prodUpgrade?: string,          // upgrade currently being researched
  prodProgress?: f32,            // 0..1
  prodQueue?: u32,               // queued count (including the in-progress one)
  // buildings under construction:
  buildProgress?: f32,           // 0..1; when present and <1, render as scaffolding
  buildActive?: bool,            // owner-only; true when server advanced this scaffold this tick
  // workers:
  latchedNode?: u32,             // node id the worker is currently harvesting (attached mining)
  // combat feedback:
  targetId?: u32,                // current attack target, for drawing tracers
  setupState?: string,           // machine_gunner/anti_tank_gun/mortar_team/artillery only:
                                  // "packed","setting_up","deployed","tearing_down"
  // unit-producing buildings:
  rally?: [f32, f32],            // first rally point (world px); ONLY ever sent to the owner
  rallyPlan?: [                  // building rally stages; ONLY ever sent to the owner
    { kind: "move"|"attackMove", x: f32, y: f32 }
  ],
  // tanks:
  oilUsed?: f32,                 // lifetime oil burned by movement, in resource units
  setupFacing?: f32,             // anti_tank_gun/artillery only: owner/allied deployed arc center; appended after oilUsed in compact snapshots
  orderPlan?: [                  // current + queued order stages; ONLY ever sent to the owner
    { kind: "move"|"attackMove"|"attack"|"gather"|"build"|"deconstruct"|"smoke"|"mortarFire"|"pointFire"|"setupAntiTankGuns", x: f32, y: f32 }
  ],
  chargeCooldownLeft?: u16,      // legacy; no longer projected by current server
  abilities?: [                  // owner-only ability affordance/cooldown data
    { ability: "smoke"|"mortarFire"|"pointFire"|"breakthrough"|"ekatTeleport"|"ekatLineShot"|"ekatMagicAnchor",
      cooldownLeft: u16, remainingUses?: u16, autocastEnabled?: bool,
      activeObjectId?: u32, availableTick?: u32, lockoutUntilTick?: u32, expiresIn?: u16 }
  ],
  breakthroughTicks?: u16,       // active Breakthrough speed status; visible only with the entity
  visionOnly?: bool,             // true = visible only through one-second death vision; visual intel only
  debugPath?: {                  // diagnostic policy only; remaining movement path; owner-only unless policy says full projected diagnostics
    waypoints: { x: f32, y: f32 }[],
    goal?: { x: f32, y: f32 },
    lastRepathTick: u32,
    stuckTicks: u16,
    staticBlockedTicks: u16,
    totalWaypoints: u16
  },
}

2.5 Event (transient, one snapshot only)

{ e: "attack", from: u32, to: u32,
  reveal?: { owner: u32, kind: string, x: f32, y: f32, facing?: f32, weaponFacing?: f32, setupState?: string },
  toPos?: [f32, f32] }                         // for muzzle flashes / tracers
{ e: "overpenetration", to: u32 }               // secondary penetration damage; no tracer/audio
{ e: "death",  id: u32, x: f32, y: f32, kind } // for death poofs
{ e: "build",  id: u32, kind: string }         // building completed
{ e: "smokeLaunch", fromX: f32, fromY: f32, toX: f32, toY: f32, delayTicks: u32 }
{ e: "mortarLaunch", from: u32, fromX: f32, fromY: f32, toX: f32, toY: f32, radiusTiles: f32, delayTicks: u32 }
{ e: "mortarImpact", from?: u32, x: f32, y: f32, radiusTiles: f32,
  reveal?: { owner: u32, kind: string, x: f32, y: f32, facing?: f32, weaponFacing?: f32, setupState?: string } }
{ e: "artilleryTarget", from: u32, x: f32, y: f32, radiusTiles: f32, delayTicks: u32 }
{ e: "artilleryImpact", x: f32, y: f32, radiusTiles: f32 }
{ e: "notice", msg: string, severity?: "info"|"warn"|"alert", x?: f32, y?: f32 }

Notices default to severity: "info" with no position. alert:-prefixed notice ids are gameplay alerts: active-player clients play alert audio and ping the minimap at (x, y) when present, or pulse the minimap border when absent. Replay viewers and live spectators keep the visual alert feedback but suppress notice alert audio. alert:under_attack is emitted at the damaged enemy unit’s position to the victim owner only; same-team recipients may still see the attack event through shared vision, but they do not receive teammate under-attack alerts. Same-team friendly-fire damage does not emit under-attack alerts. Unit attack events are sent to the attacker’s team and to enemy recipients whose team can currently see the shooter or target point. They include reveal so a shooter that fires from fog can be rendered briefly as a semi-transparent, non-interactive silhouette above the fog overlay; toPos lets tracers draw even when the hit target is no longer in the snapshot. Overpenetration events are sent for secondary entities damaged behind the primary target. They carry only the damaged entity id and do not imply a separate fired shot, muzzle flash, tracer, shooter reveal, weapon recoil, or attack sound. Death events are sent to the dead entity’s team and to enemy recipients whose team can currently see the death position; smoke-covered hidden death positions are withheld. Build completion events are sent to the completed building’s team and to enemy recipients whose team currently sees the site. Smoke launch events are team-visible local feedback for the scout-car canister animation; enemies do not receive hidden smoke launch data. The authoritative smoke cloud appears later in smokes after the reported launch delay. Mortar launch events are always sent to the firing team, with shooter id, shell origin, impact point, radius, and delay so clients can draw launch dust, recoil, the projectile, and the warning marker until detonation. Autocast mortar launch events are also sent to enemy recipients whose team currently sees the mortar; manual launch events remain hidden from enemies without current team sight, so they do not receive pre-impact warning markers. Mortar impact events are sent to the firing team, to enemy recipients with team-current visibility at the impact point, and to enemy players whose entities were damaged by the shell. An enemy damaged victim owner receives from + reveal so the attacking mortar can be shown briefly above fog after indirect fire lands. Allied or owned entities can still take mortar splash damage, but that damage is unattributed and does not reveal the firing mortar as hostile. Enemy players do not receive hidden mortar launch data or hidden mortar impact markers unless their entities were hit or their team sees the relevant point. Artillery target events are sent to the firing team so enemies never receive pre-impact markers, even if they have vision of the gun. The from id lets allied clients recoil the specific gun and draw launch dust. Enemy players receive a visual-only attack event with a shooter reveal when their team currently sees the firing gun, so the gun can be shown briefly without revealing terrain, exploration, or the target point. Artillery impact events are sent to the firing team and to enemy recipients whose team currently sees the impact point; they do not reveal terrain, update exploration, or carry entity visibility. Artillery impact damage follows the same support-fire friendly-fire attribution rule as mortar splash: owned and allied entities in the radius can take damage, but same-team damage does not produce hostile reveal, under-attack, or score attribution. Events are best-effort visual flavor; the client must not depend on receiving them.

2.6 Room time state and replay vision

roomTimeState is a reliable server message that carries the shared room-controlled time cursor/state. Replay rooms send it for playback cursor changes; dev scenario watch rooms also send it after pause/resume and one-tick step controls so clients can confirm the authoritative room-time speed and tick:

{
  t: "roomTimeState",
  currentTick: u32,
  durationTicks: u32,
  keyframeTicks: u32[],
  speed: f32,
  paused: bool,
  ended: bool,
  controllerId?: u32
}

keyframeTicks lists the replay keyframes the server has recorded so far. Clients may display them as seek marks, but a seek target is not limited to these ticks; the server restores the nearest recorded keyframe at or before the requested tick and fast-forwards from there.

livePauseState is a reliable server message that carries the authoritative live-match pause state. Normal live and branch-live match recipients receive it after start and after accepted or rejected pause/unpause transitions. Spectators receive enough state to render the paused overlay but do not receive a remaining-count value or unpause authority:

{
  t: "livePauseState",
  paused: bool,
  pausedBy?: u32,
  pausesRemaining?: u8,
  pauseLimit: u8,
  canPause?: bool,
  canUnpause?: bool
}

Each active seat has three successful pause starts per match. The server decrements the count only when a request changes the room from unpaused to paused; any active player can unpause. While live pause is active the room task skips the live simulation tick branch, so AI thinking, command-ack consumption, Game::tick, live snapshot fanout, and defeat checks do not advance, while reliable control-plane messages such as ping/pong, net reports, Give up, disconnect handling, and unpause still run.

ReplayVisionRequest selects fog/vision per viewer:

{ mode: "all" }
{ mode: "player", playerId: u32 }
{ mode: "players", playerIds: u32[] }

The server rejects unknown player ids, empty subsets, and duplicate subset ids. Vision selection is not shared between viewers unless a later protocol explicitly adds shared-view control. Replay snapshots are spectator-style authoritative fog snapshots from the selected real player ids; the default is the union of all replay players.

LabClientOp is tagged by op:

{ op: "spawnEntity", owner: u32, kind: string, x: f32, y: f32, completed?: bool }
{ op: "deleteEntity", entityId: u32 }
{ op: "moveEntity", entityId: u32, x: f32, y: f32 }
{ op: "setEntityOwner", entityId: u32, owner: u32 }
{ op: "setPlayerResources", playerId: u32, steel: u32, oil: u32 }
{ op: "setCompletedResearch", playerId: u32, upgrade: string, completed: bool }
{ op: "setVision", vision: LabVisionMode }
{ op: "issueCommandAs", playerId: u32, cmd: Command }
{ op: "exportScenario", name?: string }
{ op: "importScenario", scenario: LabScenarioV1 }

LabVisionMode is { mode: "fullWorld" }, { mode: "team", teamId }, or { mode: "teams", teamIds }. Team selections are translated to current real player ids by the room task before snapshot projection; unknown, empty, or duplicate team selections are rejected. issueCommandAs queues a normal gameplay command as the selected player only when all selected units belong to that player; mixed-owner selections are rejected instead of partitioned.

LabScenarioV1 is versioned setup JSON, not a saved snapshot:

{
  schemaVersion: 1,
  kind: "labScenario",
  name: string,
  seed: u32,
  map: { name: string, schemaVersion: u32, contentHash: string },
  players: [{
    id: u32, teamId: u32, factionId: string, name: string, color: string, isAi: bool,
    steel: u32, oil: u32, upgrades: string[]
  }],
  entities: [{
    id: u32, owner: u32, kind: string, x: f32, y: f32, hp: u32, completed: bool,
    constructionProgress?: u32, constructionTotal?: u32, resourceRemaining?: u32
  }],
  metadata: { exportedTick: u32, lab: { vision: LabVisionMode } }
}

Export returns { scenario: LabScenarioV1 } in labResult.outcome. Import validates the schema, map metadata, player/team/resource/research/entity fields, restores through the public lab Game API, applies lab vision metadata, and returns an entity id remap in outcome.entityIdMap. Transient snapshot fields, fog recipient projections, events, projectile runtime state, command logs, interpolation state, and lab operation result metadata are intentionally omitted.

Reliable lab server messages:

tFieldsMeaning
labStateroom, operatorId, role, vision, dirty, operationCountRoom-local lab control metadata. World state still travels through snapshot.
labResultrequestId, ok, op, error?, outcome?Targeted reply for every lab request accepted by the room task. Rejected requests include error; accepted setup mutations may include typed outcome metadata such as entityId.

Lab MVP protocol deliberately omits pause/step/seek controls, tick-perfect timeline/keyframes, lab simulation flags such as disabled damage or god mode, server-side public scenario storage, multi-operator conflict semantics, visual iteration hot reload, and /dev/scenario migration. Those require separate typed messages instead of overloading LabClientOp.

2.7 Observer analysis state

replayAnalysis is the compatibility wire tag for reliable observer analysis overlay/tab data that cannot be derived safely from the browser’s current projected snapshot. In replay playback it is sent to replay viewers after replay start/roomTimeState, after accepted seeks, after replay vision changes, and during replay playback ticks. Live matches send the same payload every server tick, at the normal snapshot cadence, only when at least one spectator connection is present. The server computes the live payload once per tick and sends it only to connections whose room player state is spectator: true; active-player connections, including claimed branch-live seats, must not receive this message.

{
  t: "replayAnalysis",
  tick: u32,
  players: [
    {
      id: u32,
      units: [{ kind: string, count: u32, steelValue: u32, oilValue: u32 }],
      production: [
        {
          buildingId: u32,
          buildingKind: string,
          itemKind: string,
          itemType: "unit" | "upgrade",
          progress: f32,
          queueDepth: u32
        }
      ],
      unitsLost: [{ kind: string, count: u32, steelValue: u32, oilValue: u32 }],
      resourcesLost: { steel: u32, oil: u32 }
    }
  ]
}

players lists every active observed player. units is the current living unit inventory by kind. production has one row for each owned building with a non-empty unit or research queue; progress is the front item’s completion fraction and queueDepth is that queue’s total item count. steelValue and oilValue are aggregate row values (count * configured cost), not per-unit costs. unitsLost is the authoritative unit-death count by kind. resourcesLost is intentionally narrow: the spent steel/oil value of units that died, matching unitsLost; it does not include buildings, current spending, cancelled production, refunds, harvesting, or stockpile deltas.

Observer analysis uses an all-player spectator policy independent of each viewer’s replay vision selection. It is observer-only data for analysis overlays, not an active-player information surface. Replay playback recomputes the payload from the current authoritative replay Game state after normal playback ticks and after ReplaySession::rebuild_to() restores a keyframe and fast-forwards to the target tick. Analysis state is not serialized separately in ReplayKeyframe.