Snapshot schema and wire formats
The data a game puts on the wire: port numbers, the binary snapshot frame and the schema that shapes it, the input format and the meta-data formats (authentication, panel, stats, chat, vote). The port numbers and opcodes are the engine's; the schema and the meta keys are the game's.
Ports
Server → client
| Port | Name | Format | Description |
|---|---|---|---|
| 0 | CONFIG_DATA | JSON | The client config (a merge of vimp-engine/src/config/clientDefaults.js + the game plugin's src/config/client.js (e.g. @vimp-games/tanks's) + prediction) |
| 1 | AUTH_DATA | JSON | Auth form data |
| 2 | AUTH_RESULT | JSON | Auth errors (or null) |
| 3 | MAP_DATA | JSON | Map data |
| 4 | FIRST_SHOT_DATA | JSON | The game's first frame (one-shot, bypasses the interpolation buffer): [gameSnapshot, 0, serverTime, 0] |
| 5 | SHOT_DATA | binary | The game's snapshot frame (see below) |
| 6 | SOUND_DATA | JSON | A system sound name (roundStart, victory, frag, …) |
| 7 | GAME_INFORM_DATA | JSON | On-screen game messages ([code, params?]: 0 team victory, 1 round start, 2 game over — vimp-engine/src/config/gameCodes.js is the source of truth shared by the host and the client) |
| 8 | TECH_INFORM_DATA | JSON | "Black screen" technical messages ([code, params?]: server full, loading, kicks); no data — hide the screen |
| 11 | CLEAR | JSON | A full or partial (by setId) canvas clear |
| 13 | PANEL_DATA | JSON | The HUD panel (per-user, only on change) |
| 14 | STAT_DATA | JSON | Stats (broadcast, only on change) |
| 15 | CHAT_DATA | JSON | A chat message (broadcast or personal) |
| 16 | VOTE_DATA | JSON | Vote data |
| 17 | KEYSET_DATA | JSON | The active key set: 0 — spectator, 1 — player; sent on a status change, and on a map change (the spectator set right before CLEAR) |
| 18 | ACCOLADES_DATA | JSON | Global leaderboard placements for the room's participants, produced by the host and applied client-side |
Client → server
| Port | Name | Description |
|---|---|---|
| 1 | AUTH_RESPONSE | Auth form data plus the lobby identity JWT ({model, ..., token}); the host derives the nick from the verified token, not from a form field |
| 5 | KEYS_DATA | Input: the string "seq:action:name" (see below) |
| 6 | CHAT_DATA | A chat message / command |
| 7 | VOTE_DATA | A vote response [voteName, value] or a list request ('maps', 'teams') |
The other ports — the connection handshake, session resume, RTT ping/pong, token refresh and the host debug log — are engine-internal: a plugin neither sends nor handles them. Their numbers stay reserved and are never reused.
Channel split: the hot snapshot vs. meta
On every snapshot tick (networkSendRate: 4 → 30 packets/sec) the host sends a binary frame on port 5 to every user ready to play. Meta data travels its own JSON channels, only on change:
- panel (13) — per-user; an array of
'key:value'strings, keys from the game's panel schema (t— round time is the only engine-defined key; e.g.@vimp-games/tanksaddsh— health,w1/w2— ammo,wa— the active weapon). A full panel is sent on joining the game, an empty one (keys only) to a spectator. - stat (14) — broadcast, a delta of changes (format below).
- chat (15) — a broadcast or personal message.
- vote (16) — a broadcast or personal vote.
- keyset (17) — sent precisely on a spectator↔player status change.
Binary snapshot frame (port 5)
The codec lives entirely in the Rust core (vimp-engine-core): you declare the schema, the engine packs and decodes the frame. The key registry is game data — the plugin's src/config/snapshot.js (gameConfig.snapshot); the format version stays with the engine (SNAPSHOT_FORMAT_VERSION = 5), and on a version mismatch the client drops the frame.
Entity blocks (kind from the game's snapshot schema)
Entity keys, kind values, and their data shapes are entirely game-defined in the plugin's own snapshot schema — the engine only enforces the block layout (id + typed fields). Example from the reference plugin (@vimp-games/tanks):
| Key | id | kind | Data format |
|---|---|---|---|
m1 | 1 | tanks | {gameId: [x, y, angle, gunRotation, vx, vy, engineLoad, condition, size, teamId, angvel] | null}; null — remove from the canvas |
w1 | 2 | tracers | array [startX, startY, endX, endY, bodyX, bodyY, wasHit, shooterId] |
w2 | 3 | bombs | {shotId(base36): [x, y, angle, size, time, ownerId] | null} |
w2e | 4 | explosions | array [x, y, radius] |
c1/c2 | 5/6 | dynamics | {'dN': [x, y, angle] | [x, y, angle, vx, vy, angvel]} — dynamic map elements; the velocity tail is optional (see below) |
Every float is originally rounded by the host to 2 decimals; the decoder restores values by rounding the Float32 again (the player block isn't rounded). Game entity events can carry an author id (@vimp-games/tanks uses shooterId/ownerId, added in v3) so the client can suppress authoritative duplicates of locally spawned entities (the client core, the game plugin's core/src/client/shot.rs, e.g. @vimp-games/tanks's).
Optional row tail (optionalFrom, v4). A schema may declare that its row's fields from index optionalFrom on are written only when they carry something: such a row starts with a flag byte (1 — the tail follows, 0 — the row ends after the mandatory part). @vimp-games/tanks uses it for the dynamic map elements: a resting crate ships [x, y, angle] and does not pay the 12 bytes for [vx, vy, angvel] every frame, while a crate that was just hit ships its velocities — the client predicts map dynamics next to its own tank, and a velocity estimated by finite differences between 30 Hz frames is worst exactly at the moment of impact. Decoding always yields a full-width row: a missing tail reads as zeros ("the body rests"), so the interpolator, the hot buffer and the JS side stay fixed-width.
Each schema entry is more than {id, kind}: class ('hot' — interpolated by the client between frames, 'event' — one-shot, delivered as-is in the frame) and fields — the row's field schema (name, ty: f32/u8/u16/u32, interp: lerp/lerpAngle/discrete, for class: 'hot' only). fields must match the key's Row struct in vimp-engine-core/src/snapshot.rs exactly in field count and type order (GameCore/ClientCore reject the constructor on a mismatch), and optionalFrom (if present) must point at a non-empty tail inside fields.
A field may also declare role — its role in the engine's own contract, as opposed to its game-owned name. Today there are two, and they come as a pair in the map-dynamics block: role: 'z' at index 3 and role: 'level' at index 4 make the dynamic row layered (the body's height above its level and its level). The engine used to detect that pair by field name, so renaming a field silently returned a flat row; now the roles are the only authority, and a map load fails loudly when a role sits at the wrong index, when only one of the pair is declared, or when fields named z/level occupy those two slots without declaring roles.
The third role, role: 'state', is the state byte of a map body: a u8 the engine writes right after the row head — index 5 on a layered row, 3 on a flat one — and before the optional velocity tail, so optionalFrom has to be greater than its index (a resting body sends no tail and would lose the byte). The row becomes [x, y, angle, z?, level?, state, vx?, vy?, angvel?]. The value comes from the game (SimCtx::map_body_state), its meaning is the game's. A schema without the role keeps the previous row byte for byte; a misplaced or mistyped role fails the map load.
Schema properties
| Property | Values | Notes |
|---|---|---|
id | u8, unique | the key byte on the wire; validation checks uniqueness only |
kind | indexed8 · indexed32 · list16 · indexedNoNull8 | wire shape, see below |
class | hot · event | hot = continuous state, event = one-shot |
fields[].ty | f32 · u8 · u16 · u32 | big-endian |
fields[].interp | lerp · lerpAngle · discrete (default) | only f32 fields interpolate; only hot blocks interpolate |
fields[].role | z · level · state | engine-written fields of the map dynamics row |
The field order is positionally bound to the game's Rust Row construction, and nothing validates the correspondence beyond the field count and types: swapping two same-typed fields on one side only silently produces garbage.
The four kinds
hasData is a u8 null marker: 0 — the entity is gone (JSON null), 1 — a field row follows. The body is a concatenation of [u8 keyId][block] for every key that has content this tick.
| Kind | Count prefix | Per row | Use for |
|---|---|---|---|
indexed8 | u8 count | u8 id, u8 hasData, fields | up to 255 addressable entities (players) |
indexed32 | u16 count | u32 id, u8 hasData, fields | many short-lived entities (projectiles) |
list16 | u16 count | fields | anonymous events (tracers, explosions) |
indexedNoNull8 | u8 count | u8 index, fields | fixed-slot data that never disappears (dynamic map objects) |
Interpolation
The client renders the world at serverNow − delay (interpolation.delay 100 ms, interpolation.maxFrameAge 1000 ms in clientDefaults.js); at 30 frames/s (networkSendRate: 4) that is about 3 frames of buffer. f32 fields marked lerp are interpolated linearly, lerpAngle the short way around the circle, everything else snaps.
Rounding
Every transmitted f32 effectively carries 2 decimal places, except the per-user player block (prediction needs full precision). The rounding is the game's to apply, not the packer's: the packer writes the f32 verbatim, while the decoder passes every field through round2. A value packed unrounded reaches the client as a different number than the one the host kept — call vimp_engine_core::physics::round2 on the floats given to build_snapshot_blocks. The engine does this itself for the dynamic-map-object block it owns.
Designing a schema
- One
hotindexed8key per persistent actor type (players, vehicles). indexedNoNull8for fixed-slot world state (dynamic map objects).eventlist16for anonymous one-shot effects.eventindexed32for identified short-lived entities that need updates (grenades in flight).- Keep hot field counts small: every field costs bytes 30×/s per entity.
- Put the author id last in every weapon event block — client-side duplicate suppression relies on it.
When adding a new weapon/entity, its snapshot key must be registered in the game plugin's schema (src/config/snapshot.js, e.g. @vimp-games/tanks's) — with a full fields list for its kind — or pack_body/the core constructor will throw. If the existing kind values don't fit the data shape, add a new block layout to vimp-engine-core/src/snapshot.rs + vimp-engine-core/src/client/unpack.rs and bump the format version.
Input format: "seq:action:name"
The client sends every key event as a string on port 5 (client → server):
seq— an incrementing input number (Uint32), written to the local predictor history;action—down|up;name— a command (forward,fire,nextPlayer, …).
The server keeps the user's lastInputSeq and returns it in the frame's player block — this tells the client which inputs the authoritative state already accounted for, so it only replays (reconciles) later ones. Details — client-api.md.
For a spectator, the same strings are handled by the server as switching the watched player (nextPlayer/prevPlayer).
RTT and kicks
The host pings every client (rttPingInterval, 3 s) and the client answers; the smoothed (EMA) latency is published to stats as the latency column. A client is kicked:
- at a smoothed
latency > maxLatency(1000 ms; a threshold sized for P2P hosting over home connections and spikes at a map change) — code4003; - at
maxMissedPings(5) consecutive missed replies — code4004.
All three values are engine host defaults a game may override (see game-config.md).
Meta data formats
Authentication (port 1)
AUTH_DATA (PS_AUTH_DATA) payload: { elems, params, texts } (hostPlugin.authSchema — see plugin-api.md):
elems— DOM ids the neutral auth shell exposes to the game plugin (authId,errorId,enterId,fieldsId, and, for the texts below,titleId/informsId); the plugin's values must match the ids actually present in the shell.params[]—{ name, value, options }, the per-player field schema (same field-descriptor contract asroomForm):optionscarriescontrol/label/unit/numeric/min/max/options/source/storage/regExp/required/maxlength/hiddenplus the pre-existingvalidator(a key intoauthSchema.validators, checked host-side only — validator code never crosses the wire). The client builds one form control per param viaformBuilder.jsand inserts it into#+elems.fieldsId, seeding its value fromlocalStorage[storage]when present.texts— optional{ title, sections }filled into the neutral shell'selems.titleId/elems.informsId.
Panel (port 13)
An array of 'key:value' strings, e.g. ['t:97', 'h:100', 'w1:200', 'wa:w1']. Only changed keys are sent; t (round time, seconds) — on every second change. An empty panel (for a spectator) — time plus a list of keys with no values (containers are hidden).
Stats (port 14)
statArray = [tBodies, tHead, fullUpdate?] (assembled by Stat):
statArray[0]— table rows:[row id, table number, cell array | null, tbody number].nullinstead of cells — remove the row; an empty string in a cell — clear the value;undefined/omitted — don't change.statArray[1]— headers:[table number, cell array, tHead row number].statArray[2]— a full-update flag (boolean, optional).
A player row's cells: [name, status, score, deaths, latency] (order — the key from game:stat).
Chat (port 15)
- A user message:
[text, author name, teamId]. - A system message: the string
'group:number:comma,separated,params'— the client builds the text from the game'smessagestemplates, falling back to the engine's English defaults for the engine groups (s,v,m,c;src/config/chatMessages.js). A game group (e.g. tanks'b) has no default: no text — no message.
Vote (port 16)
The server sends payload:
name— the vote's name/type (the client looks up a template inclient.js → modules.vote.params.templates);params— optional; strings substituted into the title's{0},{1}placeholders;values— optional; an array of ready-made options or a command string ('maps','teams') — the client requests the current list from the server (port 7 client → server).
The client's reply: [voteName, selectedValue]. Requesting a dynamic list: the string 'maps' | 'teams'.
The "Change host" vote (lobby mode) does not use this port: it runs between the clients and the platform, so the host it is about never sees it. Its window is an engine vote named @changeHost; its answer never goes out as [voteName, value] on port 7. Names starting with @ are reserved for engine votes.
← Previous: Game configuration reference · Next: Rust Engine Core (vimp-engine-core) →