Skip to content

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 ​

PortNameFormatDescription
0CONFIG_DATAJSONThe 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)
1AUTH_DATAJSONAuth form data
2AUTH_RESULTJSONAuth errors (or null)
3MAP_DATAJSONMap data
4FIRST_SHOT_DATAJSONThe game's first frame (one-shot, bypasses the interpolation buffer): [gameSnapshot, 0, serverTime, 0]
5SHOT_DATAbinaryThe game's snapshot frame (see below)
6SOUND_DATAJSONA system sound name (roundStart, victory, frag, …)
7GAME_INFORM_DATAJSONOn-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)
8TECH_INFORM_DATAJSON"Black screen" technical messages ([code, params?]: server full, loading, kicks); no data — hide the screen
11CLEARJSONA full or partial (by setId) canvas clear
13PANEL_DATAJSONThe HUD panel (per-user, only on change)
14STAT_DATAJSONStats (broadcast, only on change)
15CHAT_DATAJSONA chat message (broadcast or personal)
16VOTE_DATAJSONVote data
17KEYSET_DATAJSONThe active key set: 0 — spectator, 1 — player; sent on a status change, and on a map change (the spectator set right before CLEAR)
18ACCOLADES_DATAJSONGlobal leaderboard placements for the room's participants, produced by the host and applied client-side

Client → server ​

PortNameDescription
1AUTH_RESPONSEAuth form data plus the lobby identity JWT ({model, ..., token}); the host derives the nick from the verified token, not from a form field
5KEYS_DATAInput: the string "seq:action:name" (see below)
6CHAT_DATAA chat message / command
7VOTE_DATAA 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/tanks adds h — 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):

KeyidkindData format
m11tanks{gameId: [x, y, angle, gunRotation, vx, vy, engineLoad, condition, size, teamId, angvel] | null}; null — remove from the canvas
w12tracersarray [startX, startY, endX, endY, bodyX, bodyY, wasHit, shooterId]
w23bombs{shotId(base36): [x, y, angle, size, time, ownerId] | null}
w2e4explosionsarray [x, y, radius]
c1/c25/6dynamics{'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 ​

PropertyValuesNotes
idu8, uniquethe key byte on the wire; validation checks uniqueness only
kindindexed8 · indexed32 · list16 · indexedNoNull8wire shape, see below
classhot · eventhot = continuous state, event = one-shot
fields[].tyf32 · u8 · u16 · u32big-endian
fields[].interplerp · lerpAngle · discrete (default)only f32 fields interpolate; only hot blocks interpolate
fields[].rolez · level · stateengine-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.

KindCount prefixPer rowUse for
indexed8u8 countu8 id, u8 hasData, fieldsup to 255 addressable entities (players)
indexed32u16 countu32 id, u8 hasData, fieldsmany short-lived entities (projectiles)
list16u16 countfieldsanonymous events (tracers, explosions)
indexedNoNull8u8 countu8 index, fieldsfixed-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 ​

  1. One hot indexed8 key per persistent actor type (players, vehicles).
  2. indexedNoNull8 for fixed-slot world state (dynamic map objects).
  3. event list16 for anonymous one-shot effects.
  4. event indexed32 for identified short-lived entities that need updates (grenades in flight).
  5. Keep hot field counts small: every field costs bytes 30×/s per entity.
  6. 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) — code 4003;
  • at maxMissedPings (5) consecutive missed replies — code 4004.

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 as roomForm): options carries control/label/unit/numeric/min/max/options/source/storage/regExp/required/maxlength/hidden plus the pre-existing validator (a key into authSchema.validators, checked host-side only — validator code never crosses the wire). The client builds one form control per param via formBuilder.js and inserts it into #+elems.fieldsId, seeding its value from localStorage[storage] when present.
  • texts — optional { title, sections } filled into the neutral shell's elems.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]. null instead 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's messages templates, 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 in client.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) →

vimp-engine 0.38.1 · vimp-engine-core 0.23.2 · create-vimp-game 0.5.4