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)
t | Fields | Meaning |
|---|---|---|
join | name: string, room?: string, spectator?: bool, replayOk?: bool | Join (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. |
ready | ready: bool | Toggle ready state in the lobby. |
start | — | Host asks to start the match (only honored from the room host). |
setTeamPreset | preset: string | Deprecated compatibility command. The server ignores it; lobby teams are host-managed slots. |
setTeam | id: u32, teamId: u32 | Host 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. |
setFaction | factionId: string | Active 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. |
addAi | teamId?: u32, aiProfileId?: string | Host 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. |
setAiProfile | id: u32, aiProfileId: string | Host 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. |
removeAi | id: u32 | Host removes a previously-added AI opponent by id (lobby phase only, host-only). |
setQuickstart | enabled: bool | Host toggles “Debug mode” for the next match in this room. |
setSpectator | spectator: bool, id?: u32 | Switch 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. |
command | clientSeq: u32, cmd: Command | Issue 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. |
giveUp | — | Give up the active match. The server eliminates that player and sends their score screen. |
pauseGame | — | Pause a live match. Honored only from active live players while the room is unpaused and that active seat has successful pause starts remaining. |
unpauseGame | — | Resume a paused live match. Honored from any active live player while the room is paused. |
returnToLobby | — | Leave 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. |
ping | ts: number | Latency probe; server replies with pong. |
netReport | report: ClientNetReport | Periodic client-observed network/render health aggregate. Server logs notable reports for diagnostics only; it never affects simulation state. |
setRoomTimeSpeed | speed: f32 | Set 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. |
stepRoomTime | — | Advance 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. |
seekRoomTime | ticksBack: u32 | Rewind 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. |
seekRoomTimeTo | tick: u32 | Seek 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. |
setReplayVision | vision: ReplayVisionRequest | Select 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. |
lab | requestId: u32, op: LabClientOp | Privileged 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. |
requestReplayBranch | — | Request 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. |
claimBranchSeat | playerId: u32 | Claim 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. |
releaseBranchSeat | playerId: u32 | Release 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. |
startBranch | — | Host 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. |
selectMap | map: string | Host 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:
c | Fields | Meaning |
|---|---|---|
move | units: u32[], x: f32, y: f32, queued?: bool | Move 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. |
attackMove | units: u32[], x: f32, y: f32, queued?: bool | Move 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. |
attack | units: u32[], target: u32, queued?: bool | Attack a specific entity. When queued is true, store future attack intent instead of replacing the active order. |
deconstruct | units: u32[], target: u32, queued?: bool | Send 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. |
setupAntiTankGuns | units: u32[], x: f32, y: f32, queued?: bool | Manually 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. |
tearDownAntiTankGuns | units: u32[] | Pack up owned anti-tank guns that are setting_up or deployed. Other selected units are ignored. |
charge | units: u32[] | Legacy Rifleman Charge activation. Preserved for old clients/replays, but no longer has eligible carriers. |
useAbility | `ability: “charge” | “smoke” |
recastAbility | ability: "ekatTeleport", units: u32[], targetObjectId?: u32, queued?: bool | Explicit 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. |
setAutocast | ability: "mortarFire", units: u32[], enabled: bool | Toggle server-authoritative autocast for owned Mortar Teams. Other unit/ability combinations are ignored. |
gather | units: u32[], node: u32, queued?: bool | Send workers to harvest a resource node. When queued is true, store future gather intent instead of replacing the active order. |
build | units: u32[], building: string, tileX: u32, tileY: u32, queued?: bool | Selected 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. |
train | building: u32, unit: string | Queue a unit at a production building. |
research | building: u32, upgrade: string | Queue 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. |
cancel | building: u32 | Cancel the latest item in a building’s production queue. |
stop | units: u32[] | Clear orders and return selected units to ordinary idle behavior. |
holdPosition | units: 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. |
setRally | building: 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)
t | Fields |
|---|---|
welcome | playerId: u32 — assigned on connect. |
lobby | room: string, hostId: u32, players: LobbyPlayer[], canStart: bool, quickstart: bool, teamPreset: string, map: string, maps: AvailableMap[] |
matchCountdown | durationMs: 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. |
start | Game start payload (see 2.3). |
snapshot | Per-player snapshot (see 2.4). |
roomTimeState | Room-controlled time state (see 2.6). |
livePauseState | Live match pause state (see 2.6). |
replayAnalysis | Observer analysis state (see 2.7). |
joinReplayPrompt | room: string — the requested room is currently replay playback; clients should confirm before retrying join with replayOk: true. |
replayBranchCreated | branchRoom: string, sourceTick: u32, seats: ReplayBranchSeat[] — a separate practice branch room has been created from the source replay’s current authoritative tick. |
branchStaging | room: 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. |
shutdownWarning | deadlineUnixMs: u64, secondsRemaining: u64 — deploy/termination drain has started; active matches may continue until the deadline, but new match starts are disabled. |
gameOver | `winnerId: u32 |
pong | ts: number (echo of the ping ts) |
commandReceipt | clientSeq: u32, serverTick: u32, accepted: bool, reason?: string — reliable diagnostics-only room receipt. Does not reconcile prediction. |
error | msg: 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:
| Vocabulary | Codes |
|---|---|
kind | 1 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 |
state | 1 idle, 2 move, 3 attack, 4 gather, 5 build, 6 train, 7 construct, 8 dead |
setupState | 1 packed, 2 setting_up, 3 deployed, 4 tearing_down |
orderStage | 1 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 |
ability | 1 charge, 2 smoke, 3 mortarFire, 4 pointFire, 5 breakthrough, 6 ekatTeleport, 7 ekatLineShot, 8 ekatMagicAnchor |
abilityObject.kind | 1 returnMarker, 2 magicAnchor, 3 lineProjectile |
upgrade | 1 methamphetamines, 2 anti_tank_gun_unlock, 3 tank_unlock, 4 artillery_unlock, 5 mortar_autocast, 6 command_car_unlock |
notice.severity | 1 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/path | Rust owner | JS mirror path | Category | Current checker | Proposed future checker | Client-only exclusion reason | Compact version impact |
|---|---|---|---|---|---|---|---|
ClientMessage, ServerMessage, Command, lobby/replay/branch message tags and fields | server/crates/protocol/src/lib.rs | client/src/protocol.js C, S, CMD, msg.*, decodeServerMessage | wire DTO | tests/protocol_parity.mjs compares the structured Rust protocol contract dump to JS tags/builders/decoder; serde compile/tests cover Rust shape | Remaining source-text checks for DTO/lobby assertions outside the current dump scope | None; JS is a protocol mirror | No 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 DTOs | server/crates/contract/src/lib.rs, re-exported by server/crates/protocol/src/lib.rs | client/src/protocol.js decoder output consumed by client modules | wire DTO | tests/protocol_parity.mjs fixture decodes selected compact fields; Rust serde tests cover local serialization | Structured contract/schema dump for semantic DTO fields plus compact round-trip fixtures | None; JS is a protocol mirror | Compact bump only when the live compact representation changes |
terrain codes | server/crates/protocol/src/lib.rs terrain; adapter test checks rules terrain constants | client/src/protocol.js TERRAIN and PASSABLE | wire DTO / compact transport code | tests/protocol_parity.mjs extracts Rust terrain codes | Structured protocol constants dump | None | No 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_KINDS | server/crates/protocol/src/lib.rs kinds; domain identity is rts-rules::EntityKind::stable_id() | client/src/protocol.js KIND, UNIT_KINDS, BUILDING_KINDS, RESOURCE_KINDS | wire DTO plus domain adapter grouping | tests/protocol_parity.mjs checks kind code mapping; adapter tests round-trip every EntityKind; catalog parity checks many kind references | Structured protocol constants dump plus catalog export that classifies unit/building/resource groups | None | Bump 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 conversion | Rules/sim-aware adapter modules | No direct JS mirror beyond the protocol kind strings | domain adapter mapping | Rust adapter tests in both modules | One shared rules-aware adapter path with a single round-trip test | Not client data | No compact impact unless output kind strings/codes change |
states, SETUP, NOTICE_SEVERITY, REPLAY_VISION, and event discriminators | server/crates/protocol/src/lib.rs string vocabulary and event serialization | client/src/protocol.js constants and decoder | wire DTO / compact transport code | tests/protocol_parity.mjs checks state, setup, notice severity, and event compact codes; selected decoder fixtures | Structured protocol constants and compact event-shape dump | None | Bump when compact event/entity slots change |
COMPACT_SNAPSHOT_VERSION, PREDICTION_PROTOCOL_VERSION, compact top-level keys, optional entity slots, limits, and net status slots | server/crates/protocol/src/lib.rs compact serializer | client/src/protocol.js COMPACT_SNAPSHOT_VERSION, decode helpers, MAX_COMPACT_* limits | compact transport code | tests/protocol_parity.mjs source-text version checks and fixture decode | Structured compact schema dump including slot names, order, caps, and version | None | Direct 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 records | server/crates/protocol/src/lib.rs code functions and event serializer | client/src/protocol.js *_CODE maps | compact transport code | tests/protocol_parity.mjs extracts Rust functions/events and rejects duplicate or 255 real codes | Structured protocol constants dump generated from Rust instead of source scraping | None | 255 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 payloads | Protocol string modules in server/crates/protocol/src/lib.rs; catalog facts in server/crates/rules/src/faction.rs | client/src/protocol.js ABILITY, UPGRADE, ABILITY_CODE, UPGRADE_CODE; command-card descriptors in client/src/config.js | wire DTO, compact transport code, faction catalog fact | tests/protocol_parity.mjs checks protocol ids/codes; scripts/check-faction-catalog-parity.mjs checks catalog-exposed ability codes and descriptors | Structured protocol dump plus complete faction catalog dump | None where mirrored from Rust; catalog descriptors are not UI-only when exported by Rust | Code/order changes can require compact bump; descriptor-only changes do not |
DEFAULT_FACTION_ID | server/crates/contract/src/lib.rs, re-exported by protocol | client/src/protocol.js | wire DTO / faction catalog fact | tests/protocol_parity.mjs; scripts/check-faction-catalog-parity.mjs checks default catalog id | Structured contract/catalog dump | None | No compact impact |
PLAYER_PALETTE lobby colors | server/src/lobby/mod.rs assigns authoritative lobby/start colors | client/src/config.js fallback palette | server-owned presentation data mirrored by client | tests/protocol_parity.mjs source-scrapes the Rust palette | Structured lobby/config dump | Not client-only because server sends assigned colors; JS is fallback/render mirror | No 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:
t | Fields | Meaning |
|---|---|---|
labState | room, operatorId, role, vision, dirty, operationCount | Room-local lab control metadata. World state still travels through snapshot. |
labResult | requestId, 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.