Snapshot Quantization & Bit-Packing¶
Frozen decision record. This page records a decision as it was made and is not maintained against current behaviour. For how the engine works today, see the Developer Guide.
This document describes the quantized, bit-packed MsgWorldSnapshot entity encoding introduced in
515 (Epic B — wire-state quantization & snapshot scaling). It is a decision-record-style design¶
note; the normative wire spec lives in network-protocol.md.
Motivation¶
The reference-environment characterisation (#505) measured per-client downstream at ~480 KB/s at
128 idle clients — 3.2× the ≤150 KB/s scale gate. The dominant cost was the fixed, uncompressed
per-entity record: an 88-byte MsgEntityEntry (new/baseline) and a 64-byte MsgEntityUpdate
(steady state), broadcast for every visible entity every tick. A modern netcode quantizes these
fields; this epic does the same, targeting a ~3–4× reduction while staying transport-agnostic
(unchanged on the current enet6).
Design¶
Each MsgWorldSnapshot is
header(24) → origin table(originCount × double[3]) → stitched record stream(bitstreamBytes) → TLV
block. Since #775 the whole body after the header may additionally be one zstd frame
(MsgWorldSnapshotHeader::flags bit 0 + uncompressedBytes; engine-layer, transport-agnostic, raw
fallback when it does not strictly win) — everything below describes the decompressed body; the
codec seam is engine/net/SnapshotCompression.h and the framing is specified in
network-protocol.md. Positions are quantized relative to a shared per-region origin — the floor of the
entity's position onto a fixed kOriginGridM (~65 km) grid (SnapshotCodec::originForPos) — instead
of the receiving peer's position, so the sim encodes each entity once per tick (a full and a delta
blob) and each peer's snapshot is assembled by stitching the pre-encoded blobs (memcpy), not
re-quantizing per peer (#725). The header carries recordCount + originCount; the origin table holds
the distinct grid origins this snapshot's records reference (deduped); each record is prefixed with an
origin-index varint into that table.
Record layout (one stitched entity, byte-aligned)¶
| Field | Encoding |
|---|---|
originIndex |
unsigned varint into the origin table (written at stitch time, not baked into the blob) |
idx |
unsigned varint of the absolute entityIdx (peer-independent → blobs stitch in any order) |
full |
1 bit — full record (carries typeIndex + factionIndex + gen) vs. delta |
genPresent |
1 bit — generation is on the wire (else the client reuses its cache) |
omegaPresent |
1 bit — angular rates present (set only for the receiving peer's own entity) |
gen |
16 bits, only if genPresent |
typeIndex |
varint, only if full |
factionIndex |
16 bits, only if full (#860) — FactionRegistry index, client-cached alongside typeIndex |
| position | 3 × kPosBitsPerAxis (22), signed offset from the record's shared grid origin at kPosStepM (0.125 m) |
| orientation | 2-bit dropped-component index + 3 × kQuatBits (10) — smallest-three |
| velocity | 3 × kVelBits (18), range ± kVelMaxMps (2000) |
| omega | 3 × kOmegaBits (12), range ± kOmegaMaxRadS (20), only if omegaPresent |
| loadout block | 64 bits, only if omegaPresent (#625): selectedStation(8) + stationRounds(16) + weaponFlags(8, bit 0 = seeker locked) + payloadMassKg(16, 1 kg steps, clamped [0, 65535]) + payloadCd0(16, 1e-5 steps, clamped [0, 0.65535]) |
| byte fields | damageLevel(3) + engineFailFlags(5) + throttle(7) + fuelPct(7) + abEngaged(1) + playerOwned(1) |
| padding | zero bits to the next byte boundary (so the next record's originIndex starts on a byte) |
Constants live in engine/net/SnapshotCodec.h and are tuned against the bot_swarm
downstream_kbs_per_client metric. Representative blob sizes: a steady-state delta blob is
24 bytes, a full own-entity blob (typeIndex + factionIndex + gen + omega + loadout) is 41 bytes; the stitched record
adds the origin-index varint (1 byte for a small index). These blob sizes are locked by a golden-bytes
test in test_snapshot_codec. The encode-once trade adds a small per-record overhead (origin index +
byte alignment) plus the per-snapshot origin table in exchange for O(entities) encode instead of
O(peers × visible).
Why these choices¶
- Shared-grid-origin-relative position (#725). Storing a
doubleper entity is wasteful; storing absolutefloatloses precision at planet scale. Encoding the offset from a shared grid-celldoubleorigin keeps fine resolution everywhere while making a record peer-independent: the same quantized blob is valid for every peer, so it is encoded once per tick and stitched (memcpy) into each peer's stream.kPosBitsPerAxis = 22atkPosStepM = 0.125covers ±262 km — far beyond the ~65 km grid cell, so the offset always fits. The grid origins actually referenced by a snapshot are carried (deduped) in its origin table as exactdoubles, so precision is uniform at planet scale. (The old per-peerframeOrigingave the peer's own entity a near-exact position as an artifact offrameOrigin == own position; under the shared origin the own entity is quantized to 0.125 m like every other entity.) - Smallest-three quaternion. A unit quaternion's largest component is reconstructed from the other three (each in ±1/√2), so 32 bits replaces 16 bytes with imperceptible error after renormalization.
- Omega only for the own entity. Body-frame angular rates are consumed solely by client-side prediction reconciliation, which only runs for the player's own entity. Every other record omits them.
- Loadout rides the same own-record bit (#625). Selection, rounds, weapon flags, and the LIVE
payload (mass + drag of what is still on the rails) matter only to the owning client — the HUD
weapon line and
ClientPrediction, which re-resolves itsPayloadEffectfrom the record so a released store changes client-predicted physics the same tick it changes the server's. Other peers see stores as spawned projectile entities, not as loadout state. Reusing theomegaPresentgate costs no new flag bit; the per-type static payload inMsgEntityTypeDefremains only as the pre-first-snapshot fallback. - Generation only when it changes. In steady state an entity's generation never changes, so the
16-bit field is replaced by a single
genPresent = 0bit; the client reuses its cache. A generation change is, by construction, classified as afullrecord on the server.
Portability¶
The codec is byte-identical on every supported target (a Linux server and a Windows client must agree bit-for-bit):
- Bits are assembled MSB-first with shifts/masks into a
uint8_tbuffer — nevermemcpyof a native multi-byte int — so byte order never reaches the wire. - Quantization is arithmetic (
value × scale, rounded withstd::lround), never an IEEE bit-cast, so float representation never reaches the wire. - Signed values use offset-binary, so only unsigned shifts/masks are ever applied (no implementation-defined signed-shift behaviour).
- The accumulator is
uint64_tand each read/write moves ≤ 32 bits with ≤ 7 leftover bits, so the running width stays ≤ 39 bits — no shift-by-≥width UB (enforced by the ASan/UBSan CI job). - NaN/Inf inputs are clamped before any float→int cast.
- The bitstream is byte-addressed, so there are no misaligned multi-byte loads on ARM64; only the
24-byte header is read via
WireCodec readMsg(memcpy); the origin table'sdoubles are 8-aligned after it.
Validation¶
tests/test_snapshot_codec.cpp— bitstream round-trip (incl. truncated-buffer fail-closed), smallest-three for all four largest-component cases, clamp paths, planet-scale position, golden byte sizes, and a bandwidth-regression guard (records strictly smaller than the old 64 B).tests/test_world_broadcaster.cpp— server encode + full/delta/baseline/respawn behaviour, the TLV-after-bitstream offset, and the 3D (XYZ) interest cull (#402).tests/test_client_net_event_handler.cpp— client decode, the typeIndex/gen cache, and the stale-gen guard.- Bandwidth acceptance is measured end-to-end by the bot_swarm
downstream_kbs_per_clientmetric against the ≤150 KB/s gate (see load-testing.md).
Relationship to the rest of Epic B¶
This change is the encoding layer only. The per-client priority/budget snapshot scheduler (#516 —
landed) builds directly on top: it reuses estimateRecordBytes() (added here, mirroring the encoder's
bit layout) to fit the highest-relevance records into a per-client byte budget, and adds the
SnapshotDespawn TLV + client-side entity retention so budget-deferred entities don't flicker. See
engine/net/SnapshotScheduler.{h,cpp} and network-protocol.md. Client-acked
delta baselines (#517 — landed) build on this codec's per-record full bit: the server keys
full-vs-delta off the snapshot tick each client echoes in MsgClientInput/MsgHeartbeat (the ack),
re-sending a full every tick until the peer confirms it — no wire change to this codec. Selective-ack
identity precision (#566 — landed) tightens that confirmation: the client→server ack carries a 32-bit
bitmask (ackMask, engine/net/AckWindow.h) of recently decoded ticks, so the server confirms the
specific tick a full was sent in rather than a high-water mark — closing the residual where acking a
later tick could falsely confirm a full the client never decoded, and retiring the #517 deferral guard.
Adaptive send-rate/congestion response (#518) builds on the codec and the scheduler, and remains a
separate sub-task of #495.