Skip to content

Plugin API ​

The contracts below are the interface between the engine and a game plugin; the model behind them is in overview.md.

The engine is an application (deployed once: the platform's lobby and signaling, transport, Worker infrastructure, meta mechanisms, client MVC framework, Rust framework). A game is a dynamic plugin: JS bundles (client/host), a WASM binary and assets, loaded by a manifest the platform serves. One platform serves many games.

Four contracts, labelled by the constant ENGINE_API_VERSION (owned by the engine, vimp-engine/src/config/opcodes.js, frozen at 4). The label is no longer a gate: a plugin is never rejected for its age. The only reason to reject one is that it asks — through the optional requires field of its manifest — for a capability this engine build does not have, which means the plugin is newer than the engine:

  1. GameManifest — JSON describing the game build (platform → lobby/host/client);
  2. HostPlugin API — default export of the game's host entry (worker-safe);
  3. ClientPlugin API — default export of the game's client entry;
  4. Wasm Host ABI — the mandatory method set of the game's WASM classes.

GameManifest ​

Generated by the game build into the game plugin's dist/manifest.json (e.g. @vimp-games/tanks's); the version is a content hash of the bundles. The platform never executes game code — it only needs the manifest and the static map JSON (a maps:export product of the game build).

jsonc
{
  "id": "tanks",
  "engineApi": 4,
  "version": "<hash>", // gameVersion (client+host+wasm content)
  "title": "VIMP Tanks", // for the lobby
  "entries": {
    "client": "/games/tanks/client-<hash>.js", // ESM, default export = ClientPlugin
    "host": "/games/tanks/host-<hash>.js", // ESM worker-safe, default export = HostPlugin
    "wasm": "/games/tanks/core-<hash>.wasm", // single hashed .wasm for both entries (shared HTTP cache)
    "wasmNode": "./core-node/index.js", // OPTIONAL: Node build of the core inside dist/, for `npm run sim` and the dedicated server
  },
  "assetsBase": "/games/tanks/", // base for the package's own assets:
  // sounds/ and img/ (see below)
  "requires": ["accolades"], // OPTIONAL: engine capabilities the game
  // cannot run without (see below)
  "maps": { "version": "<hash>", "list": ["pool mini", "canopy", "garden"] },
  "roomDefaults": {
    "maxPlayers": 8,
    "roundTime": 120000,
    "mapTime": 600000,
    "friendlyFire": false,
    "map": "pool mini",
  },
  "roomForm": [
    // regExp AND min/max on maxPlayers/roundTime/mapTime are generated by
    // build-game-manifest.js from the same bounds — an exact numeric-range
    // pattern (e.g. "^([1-8])$" for maxPlayers 1..roomDefaults.maxPlayers)
    // plus the two numbers it was built from, not typed by hand in game.js;
    // omitted here for readability
    {
      "name": "maxPlayers",
      "control": "text",
      "label": "Max players",
      "numeric": true,
      "min": 1,
      "max": 8,
      "regExp": "<generated>",
    },
    {
      "name": "roundTime",
      "control": "text",
      "label": "Round time",
      "unit": "s",
      "numeric": true,
      "min": 10,
      "max": 3600,
      "regExp": "<generated>",
    },
    {
      "name": "mapTime",
      "control": "text",
      "label": "Map time",
      "unit": "s",
      "numeric": true,
      "min": 10,
      "max": 3600,
      "regExp": "<generated>",
    },
    { "name": "friendlyFire", "control": "checkbox", "label": "Friendly fire" },
    { "name": "map", "control": "select", "label": "Map", "source": "maps" },
  ],
}

roomForm is the explicit, ordered field-descriptor array the "Create server" form is rendered from (see Form schema below); roomDefaults stays the source of default values and the initial set of room keys sent to the host. A manifest without roomForm renders an empty room-creation form (with a console warning) instead of guessing controls from roomDefaults value types.

entries.wasmNode is optional and never used by the browser: it is a path (relative to the manifest) to a Node build of the same WASM core, used by the headless runner npm run sim -- --game <package> (see debugging.md) and by a dedicated server (the same match in a Node process), which runs the authoritative match inside a Node process. For a game meant to be hosted on a dedicated box the field is therefore effectively mandatory: there is no --core override there, and a game without it fails at server startup. It must point inside the published dist/ (conventionally ./core-node/, where the build copies its own core/pkg-node/): a wasm-pack output directory is usually git-ignored and npm applies ignore rules inside directories listed in files too, so a manifest pointing outside dist/ works in a checkout and breaks in the installed package. Without it the game can still be run headlessly by passing --core <path> explicitly; a game that has neither simply cannot be simulated on its real core. If the field is declared, the file must exist in the package as published — the loader checks it and names the contract, and it also re-checks the plugin halves it imports against the manifest's engineApi (a rebuilt manifest next to a stale dist/ is a failure, not a green run).

A half loaded in Node must be self-contained. External dependencies — pixi.js above all — are allowed only in the client half and only in the browser, where the import map resolves them (contract rule A1 keeps pixi.js out of dependencies so that engine and game share one PixiJS instance). Node therefore imports the client half on demand: the dedicated server never touches it (the browser gets it as static files), while the headless runner, whose virtual clients need it, asks for it up front. The host half and entries.wasmNode are always imported in Node, so an unresolvable import in them is a startup failure — reported as a named error naming the entry and the missing package, not a raw ERR_MODULE_NOT_FOUND.

Because of it, createCore/createClientCore receive two shapes of wasmUrl: the browser passes the .wasm asset URL, the headless runner passes a file: URL of the Node glue module (wasmNode). A plugin that wants headless runs branches on the suffix — with the --target nodejs build the wasm is loaded by the module itself, so there is no init() to call:

js
async createCore(coreConfigJson, { wasmUrl }) {
  if (wasmUrl?.endsWith('.js')) {
    const node = await import(/* @vite-ignore */ wasmUrl);

    return new node.GameCore(coreConfigJson);
  }

  await init({ module_or_path: wasmUrl });

  return new GameCore(coreConfigJson);
}

Projections: the platform — the whole manifest + the map files; the host — entries.host (dynamic import inside the Worker) + entries.wasm + maps from the platform; the client — entries.client (dynamic import after picking a room) + entries.wasm + assetsBase. Rich schemas (panel, texts, keysets) are not part of the manifest — they travel as plugin code and, as today, as CONFIG_DATA (port 0) from the host, so the client's game data is always consistent with the room's host.

assetsBase is the root of everything the package ships as a file, by convention two directories inside dist/:

PathContentsWho resolves it
${assetsBase}sounds/the webm+mp3 pair of every soundthe engine (SoundManager, it overwrites sounds.path)
${assetsBase}img/tile sheets and dynamic-body sprites of the mapsthe plugin's own part, from the assetsBase service

The engine serves no game files of its own: a map's spriteSheet.img name is resolved against the plugin's package, not against the engine's bundle. The base reaches a part through the assetsBase service — declare it in componentDependencies (see client-api.md).

The platform rewrites these URLs in the manifest it serves. A game build knows nothing about the address its version will be served from, while the platform keeps several versions of one game side by side and serves each under its own versioned base. So it moves assetsBase and entries.client/host/wasm onto that base and adds mapsBase — the lobby reads map URLs from that field and falls back to the unversioned maps address when it is absent (dev, standalone, dedicated).

Two consequences for a game author:

  • entries.wasmNode is never rewritten — it is a path inside dist/ for Node, not a URL.
  • an entry that does not sit under the manifest's own assetsBase (an absolute URL to your own CDN, say) is left exactly as written rather than "fixed" — and a package whose manifest is malformed is refused at intake by the platform's structural check, which never executes plugin code.

Form schema ​

A single field-descriptor contract, shared by the room-creation form (roomForm, above) and the per-room auth form (authSchema.params[].options, below) — both are rendered by the same engine module, formBuilder. The engine renders only what the descriptor says; there is no inference of a control from a value's type. Every control is a plain native form element — no themed/custom markup — so the game plugin's form always looks like the rest of the page.

Purpose split (important for the contract):

  • Room form (main lobby) — server settings: player limit, team count, round/map time, map, friendly fire.
  • Auth form (#auth, shown per room) — player settings: color, model, weapon, etc.

A form is an ordered array of descriptors — array order is field order:

js
{
  name:    'maxPlayers',      // value key
  control: 'text',            // 'select'|'text'|'checkbox'|'radio'
  label:   'Max players',     // falls back to `name` if omitted
  default: 8,                 // initial value
  hidden:  true,               // field is built and submitted, but no .form-row is rendered
  // numeric text fields (unit set, or numeric:true): the value is parsed as
  // a Number and converted through the same unit as `default`/the stored
  // value (ms for unit:'s') — formBuilder converts to/from the displayed
  // unit itself. Reading such a field never yields 0 for empty/invalid
  // input — it falls back to `default` — but the submit does not get that
  // far: a numeric field is implicitly `required` ("required" when empty,
  // "must be a number" when unparseable), because silently creating a room
  // on the default is not what the player asked for
  numeric: true,
  unit:    's',                // value is stored in ms, displayed/edited in seconds
  // bounds of a numeric text field (display unit, e.g. seconds for
  // unit:'s'): rendered as a "(min–max)" suffix on the label (combined with
  // the unit, e.g. "(s, 10–3600)"), and enforced by collectFormErrors below.
  // Either may be omitted (shown as "…" in the hint, unchecked in
  // validation)
  min: 1,
  max: 8,
  // validation (text): the engine never shows native browser validation
  // popups — collectFormErrors()/renderFormErrors() (formBuilder.js) check
  // every rendered control before submit (room form: click on "Create
  // server"; auth form: click on "#auth-enter") and render every failing
  // field as a line in #lobby-error/#auth-error, replacing reportValidity()
  // — the line is titled with this field's `label`
  regExp:    '^#[0-9a-f]{6}$', // matched against the WHOLE displayed value:
                              // the engine wraps it in ^(?:…)$ exactly as a
                              // browser applies the `pattern` attribute, so
                              // write it unanchored (own ^/$ are harmless).
                              // One that does not compile is not a constraint
                              // at all: the field passes and the engine logs a
                              // console.error — `vimp-contract` (rule B5)
                              // catches it before it ever ships
  required:  true,
  maxlength: 32,
  // choices (select/radio):
  options: [{ value, label }],// or a plain array, e.g. ['a', 'b']
  source:  'maps',            // engine-catalog source of choices instead of `options` (maps only, today)
  // auth-specific (player settings):
  storage: 'playerColor',     // localStorage key to remember the choice across sessions
}

control → markup, all rendered by formBuilder.js as plain native elements:

  • select — <select> (from options or source:'maps'); a select (or radio) descriptor resolving to exactly one choice is built and submitted like every other field, but its .form-row is not rendered — there is nothing for the player to actually choose. That one choice is then also forced: setValue() on the field is a no-op, so neither a remembered storage value nor a default for a choice since removed from the list can desync the hidden control. Resolving to zero choices is a schema/catalog defect, not "nothing to choose": the row is rendered (empty), the engine logs a console.error, and the field always fails validation with no options available — required or not, since there is no value to submit and "required" would send the player somewhere empty.
  • text — <input type=text>; numeric fields (numeric/unit) convert to/from the stored unit. regExp/required are mirrored onto the control as pattern/required for semantics only (neither form is a <form> and the engine never calls reportValidity()); maxlength is the one that still acts on its own, by capping the input.
  • checkbox — <input type=checkbox> (boolean settings).
  • radio — a group of <input type=radio> sharing one generated name, one per option.

The control vocabulary is append-only. It lives in an engine registry (formControls) that never loses a row: a name a published game already wrote into its manifest keeps working forever (I1). Cutting the set down is what broke v2 → v3 — the four controls dropped then are back, permanently, as aliases of their native replacements:

Retired controlBuilt asSince
rangetext + numeric: trueretired in v3
numbertext + numeric: trueretired in v3
togglecheckboxretired in v3
segmentedradioretired in v3

The alias resolves before the builder is picked and before validation, so a retired numeric field keeps its min/max/regExp checks rather than degrading into free text. A new game should declare the native control itself: vimp-contract (rule B5) reports a retired one as a warning, not an error. An control the registry does not know at all is a plugin asking for the future: the field is skipped with a console.error and the rest of the form still renders.

Validation split. roomForm travels to the client as JSON in the game manifest (/games/<id>/manifest.json) — functions don't survive JSON, so the room form only gets the declarative checks above, applied in this order: no options available (an empty select/radio) → emptiness (required, always on for a numeric field; a text value is trimmed first, so a field holding only spaces counts as empty) → must be a number → min/max → maxlength → regExp. The range is checked before the pattern on purpose: a generated regExp encodes the same bounds, but "must be ≤ 32" repeats the hint in the label while "invalid format" says nothing — the pattern is left to catch what the range cannot (a fraction, a leading zero). fields with no rendered row — hidden: true and the single-choice select/radio above — are skipped by validation: the player can neither see nor fix an error on them, so reporting one would only lock the form. The authoritative bound on room values is still enforced by the host Worker at room creation (the timer/limit clamps in applyRoomOverrides.js, called from the host Worker). The auth form comes from plugin code (authSchema) instead, so it gets both the same declarative checks and JS validators (authSchema.validators, resolved by validateAuth on the host and mirrored on the client, rendered through the same renderFormErrors).

For the auth form the host repeats the declarative part itself, so that a client bypassing the form gets no more rights than one that filled it in. validateAuth resolves retired control aliases first — exactly as the form builder does, so a field is validated as the control it was built from — and then checks, before the JS validator:

  • a control the registry does not know at all: unknown control. The builder refuses to build such a field, so it never reaches the host from an honest client; C10 reports it statically.
  • a numeric text field (numeric: true or unit) on its own branch, because the form submits it as a number: must be a number, then min/max compared in the display unit (toDisplay, shared with the builder). regExp is deliberately not applied here: the client matches the pattern against the raw string the player typed, and the host only ever sees the number, so re-stringifying it would reject a canonical spelling the form accepted (1.50 arrives as 1.5) and lock the player out with no string that satisfies both sides.
  • otherwise: length (too long), membership in a select/radio field's declared options (not an option — a browser cannot submit anything else, so neither can a bypassing client), then regExp (invalid format, anchored exactly as in the browser). maxlength and regExp apply to text fields only, exactly as in the form.

A select/radio field whose options list is empty, absent, or replaced by source accepts nothing: the auth form is built without ctx.sources, so such a field resolves to an empty list, shows no options available and cannot be filled in at all — letting the host through would hand a bypassing client what a player cannot have. C10 rejects source in authSchema outright; in roomForm (rule B5) it stays the normal way to offer the map catalog.

A field with no maxlength is still capped at 256 characters: the game's regExp now runs on the host — the Worker holding the authoritative match, or the whole dedicated process — and a catastrophic pattern such as (a+)+b turns a few dozen characters into minutes of a blocked event loop.

Two deliberate gaps. required is not enforced on the host: the solo path (boot.autoAuth) answers with the schema defaults, and those may be '', so an empty value skips the checks above and is left to the game's validator. And a regExp that does not compile is a schema defect, not a restriction, so the field passes (the same as on the client).

A validator name that authSchema.validators does not provide as a function leaves the field checked by nobody: the host skips it silently rather than calling a non-function. C10 reports it statically, and the host writes a console.error when the port machine is built, for those who do not run the contract checker.

A form checks itself as the player types: a value out of range is reported the moment it is entered, not on the next click, and a line disappears when its own field is fixed, so the list of what is still wrong survives the first keystroke. Before the first submit only fields the player has actually touched can report anything — required on a field nobody has opened yet is noise; the submit lifts that filter, because a click answers for the whole form. Rebuilding the form (switching games in the lobby) puts it back.

Errors pushed by the server (AuthView.renderError) are a snapshot of the last submit: the engine cannot re-check them, so any edit to the form clears them and the next submit brings back whichever still apply. Text values are trimmed on the way out as well as on the way in — what the field reports is the same string that was checked, so the client never passes " Bob " and leaves the host to reject it.

Where each half of the contract lives:

  • Room form: GameManifest.roomForm (next to roomDefaults, which remains the source of default values and the initial room-key set).
  • Auth form: the same descriptors travel over the wire in PS_AUTH_DATA.params[].options — see snapshot.md — params[i] is { name, value, options } where options carries control/label/unit/numeric/min/max/options/source/storage/regExp/required/maxlength/hidden (plus the pre-existing validator key, resolved against authSchema.validators).

A manifest/authSchema without a schema renders an empty form (room form: console warning; auth form: no fields, #auth-enter still works) instead of silently falling back to type-based inference — a plugin migrating to this contract gets a visible signal, not silent field loss.

HostPlugin API ​

Default export of the game's host entry. Must be worker-safe (no DOM, no Node globals).

js
export default {
  id: 'tanks',
  engineApi: 4,
  async createCore(coreConfigJson, { wasmUrl }) { /* init(wasmUrl); return new GameCore(...) */ },

  gameConfig: {                       // the game half of the former config/game.js
    teams: { team1: 1, team2: 2, spectators: 3 },   // any number of teams — required
    spectatorTeam: 'spectators',                    // optional: defaults to the 'spectators' key of teams
    // noSpectators: true,            // opt-in: no spectator team at all — exactly one team,
    //                                // a joining human enters it directly (no vote)
    // endlessRound: true,            // opt-in: the engine never restarts the round by itself
    // migration: { midRound: true }, // opt-in: a new host continues the round from the same tick
    parts: { models, weapons, friendlyFire },   // from the game plugin's src/data (e.g. @vimp-games/tanks's)
    snapshot,                         // the snapshot key schema (config/snapshot.js) — required
    playerKeys, // spectatorKeys are engine-owned (spectating is an engine mechanism)
    panel: { fields: { health: {key:'h', value:100}, w1: {…}, w2: {…} }, activeKey: 'wa' },
    stat:  { columns: {name:{…}, status:{…}, score:{…}, deaths:{…}, latency:{…}} },
    scripted: { namePrefix: 'Bot', defaultModel: 'm1' },   // replaces Bot${id}/'m1' hardcodes
    mapScale: 0.3, mapSetId: 'c1', mapsInVote: 4, defaultMap: 'pool mini',
    lobbyInfo: 'map',                 // optional: the room's lobby card shows the current map
    chatMaxLength: 60,
    initialVote: 'teamChange',        // replaces the SocketManager.sendFirstVote hardcode
    soundCues: { roundStart:'roundStart', victory:'victory', defeat:'defeat',
                 frag:'frag', death:'gameOver' },          // replaces SocketManager hardcodes
  },

  buildClientGameConfig(),            // the game section of CONFIG_DATA (see below)
  // the core init JSON is assembled by the engine itself from gameConfig
  // (vimp-engine/src/lib/coreConfig.js) — no plugin hook is needed
  authSchema: { elems: { authId:'auth', errorId:'auth-error',
                         enterId:'auth-enter', fieldsId:'auth-fields',
                         titleId:'auth-title', informsId:'auth-informs' },
                params: [
                  { name: 'model', value: 'm1', options: {
                      control: 'select', label: 'Model',
                      options: ['m1', 'm2'], storage: 'playerModel',
                      validator: 'isValidModel' } },
                ],
                validators: { isValidModel: v => v in models },
                texts: { title, sections } },   // form texts for the neutral auth shell

  onCoreEvent(data, { vimp, panel }),  // 'custom' events only; standard ones are routed by the engine
  chatCommands: [{ name: '/bot', handler(ctx, gameId, args) {…} }],   // required — registered in CommandProcessor
  systemMessages: { BOT_PLAYERS_ONLY: 'b:0', … },                     // merged into the engine code registry
  // there are no static vote definitions: game votes are created dynamically
  // via ctx.voteCoordinator.createVote(...) from chat-command handlers

  createModules(ctx) { return { scripted: new MyBotManager(ctx) }; },
  // ctx = { participants, coreAdapter, panel, stat, chat, socketManager,
  //         scripted /* gameConfig.scripted parameters */,
  //         lobby /* { setInfo(text | null) } — absent on older engines */ }
  // chat-command handlers get a different, meta-level ctx from
  // CommandProcessor: { participants, chat, scripted, roundManager,
  //   voteCoordinator, timerManager, teams, spectatorTeam, spectatorId,
  //   isDevMode }
  // Scripted-module contract (called by the engine — RoundManager/HostGame):
  //   createMap(scaledMapData), createScripted(count, team?), removeScripted(team?),
  //   removeOneForHuman(team), getCount(), getCountsPerTeam()
  // Any module may add serializeState() → JSON and restoreState(state):
  //   host checkpoints (see "Host migration" below)
};

gameConfig fields and their defaults ​

The engine reads gameConfig through one module — gameConfigView. createGameConfigView(gameConfig, gameId) validates the required paths, fills in a default for everything else and returns a frozen view; the host, the core config, applyRoomOverrides and the standalone SDK all read that view, never the raw object (an ESLint rule enforces it). A game that never heard of a field is therefore still valid — invariant I2 below.

Required — four paths, and the list can only shrink:

PathWhy the engine cannot default it
parts.modelswhat a participant is made of; nothing to synthesize
playerKeyswithout them the core has no input
snapshotthe frame layout; the engine does not invent one
teamsParticipantManager picks the team a joiner enters

Everything else has a default:

FieldDefaultEffect when omitted
titlenullthe lobby falls back to HostPlugin.id
maps{}maps come from the platform's catalog (room.maps)
currentMapnullthe first map of the catalog is used
mapsInVote1one map per map vote
mapScale1the core scales geometry 1:1
mapSetIdnullno default tile set id
roomDefaults.maxPlayershostDefaults.maxPlayers (30)the engine limit caps the room
parts.weapons{}a game without weapons (@vimp-games/snakes)
parts.friendlyFirefalsefriendly fire off unless the room turns it on
panel.fields{}an empty panel renders correctly
panel.activeKeynullno highlighted panel cell
stat{}no game columns beyond the engine layout
scripted{}ParticipantManager uses its own naming
playerState{}no default persistent state
soundCues{}the engine sends no sound cues
initialVotenulla joiner enters without a vote
statMode'table'the engine draws the room table on Tab
noSpectatorsfalsespectators exist as a concept
endlessRoundfalsethe engine restarts the round itself
lobbyInfonullthe room's lobby card shows no text of the game
migration.midRoundfalsea host change moves the meta and restarts the round (see below)
spectatorTeamderivednull under noSpectators; otherwise the spectators key of teams, else null with a console.warn

The lobby card text (lobbyInfo) is the game's own line on its room's card in the lobby (gameId/roomId, then <text> · players/max · region). lobbyInfo: 'map' shows the room's current map and follows map changes; any other value, or no field, shows nothing. A module can set an arbitrary text at run time — a server option, a mode, a phase — through ctx.lobby from createModules: lobby?.setInfo('Hardcore') (it wins over 'map'; setInfo(null) withdraws it). The text is trimmed and capped by the platform (48 characters). lobby is optional chaining on purpose: an older engine passes no lobby, and the game needs no requires entry for it.

Host migration (migration.midRound). A room can move to another host — the engine takes a checkpoint of the match (see Save / restore) and raises it on the new one. By default the move is soft: the meta (participants, score, map with its remaining time, profiles) travels, and the round starts anew. migration: { midRound: true } is the game's promise that its state survives a move in the middle of a round:

  • GameSim::serialize/deserialize carry everything the core simulation owns (the engine sends the serialize_state dump);
  • every module from createModules that keeps state in memory implements serializeState() → a JSON value and restoreState(state). The engine calls them by the module's key in the createModules object while taking and restoring a checkpoint (restore happens after the engine restored its own meta, the map included). A module without the methods gets nothing carried. A module whose serializeState throws turns the checkpoint soft.

The capability host.migration names this in the engine; a game does not need to put it in requires — an older engine ignores the field, and the game migrates softly there. The check is the vimp-sim step checkpointRestore (or --checkpoint-every <ms>), see debugging.md.

Consistency of what the game did send is still checked, and still throws: a declared spectatorTeam must be a key of teams, and noSpectators requires exactly one team. Contract rule B3 reports every field left to a default as a warning, so relying on one stays a deliberate choice.

participants also lets the game colour a player's nickname in chat: participants.setChatColor(gameId, '#rrggbb') (or null for the team colour). It is set once, when the game learns the player's colour, and the engine applies it to every message that player sends — see createModules.

HostPlugin: the real obligation of each field ​

FieldRequired?Notes
idyesmust equal manifest.id
engineApiyesgeneration label; must match the manifest and the client half
createCore(json, { wasmUrl })yesasync, returns the GameCore instance; wasmUrl has two shapes
gameConfigyesvalidated field by field, see above
authSchemayessent to every joining client
chatCommandsyes, arraythe engine iterates it without a guard — omitting it throws at boot; use [] for none
createModulesyesthe engine calls it and reads .scripted off the result
buildClientGameConfig()yescalled unconditionally when CONFIG_DATA is built
systemMessagesoptionalmerged into the chat code registry
onCoreEvent(data, ctx)optionalreceives custom core events only; ids arrive stringified

There is no views field — Panel and Stat rendering belong to the engine.

Config merge and room overrides ​

The engine builds the match config as structuredClone({ ...hostDefaults, ...view }) and then applies the room (applyRoomOverrides). The merge is shallow at the top level: a game that supplies timers replaces the whole engine timers object and must repeat every key it still wants; the same holds for rtt and idleKickTimeout. The defaults themselves are listed in game-config.md.

Trap: the core's own timeStep is read from the engine hostDefaults when the core config is built, not from the game's merged gameConfig.timers.timeStep. Overriding timers.timeStep changes the Worker loop but not the physics step the core was configured with.

applyRoomOverrides honours exactly this set and nothing else from the room form:

FieldEffect
mapsreplaces the bundled catalog with the platform's; if the default map is gone, the first catalog map is used
maxPlayersclamped to 1 .. roomDefaults.maxPlayers
mapapplied only if present in the catalog
roundTimeclamped to roomTimeMin .. roomTimeMax
mapTimeclamped the same way
friendlyFireboolean only
isDevModeboolean only; the host tab's dev mode (recorder, host CONSOLE log) — the production bundle never sets it

Every other room-form field is silently dropped, so a game's rules cannot depend on a custom room setting: there is no path for it to reach the host.

createModules(ctx) and the scripted-module contract ​

ctx is exactly { participants, coreAdapter, panel, stat, chat, socketManager, scripted, lobby }:

  • participants — the participant registry (humans and scripted ones): get, getAll, getHumans, getScripted, setChatColor(gameId, color);
  • coreAdapter — the JS wrapper over the WASM GameCore;
  • panel, stat, chat, socketManager — engine meta modules;
  • scripted — the gameConfig.scripted config object ({ namePrefix, defaultModel }), not a module;
  • lobby — { setInfo(text | null) }, see the lobby card above.

There is no timerManager and no voteCoordinator here — they exist only in the chat-command context. A bot manager that needs timing is driven from the core's AI tick or from a chat command. Only the scripted key of the returned object is read by the engine (other modules are harmless but never called, except for the serializeState/restoreState hooks).

The engine calls exactly five methods on the scripted module:

MethodWhenReturns
createMap(scaledMapData)on every map load (data already scaled)—
getCountsPerTeam()when balancing teams{ teamName: count }
removeScripted(team?)clearing bots (all, or one team)—
createScripted(count, team?)spawning botsnumber actually created
removeOneForHuman(team)a human needs a slot in a full teamboolean — freed or not

getCount() is not called by the engine; add it only for the game's own chat commands.

Nickname colour in chat. By default a nickname takes its team's colour (line${teamId} classes). A game where the team is not what tells players apart sets the colour itself: participants.setChatColor(gameId, '#ff4d4d') ('#rgb' or '#rrggbb'; anything else is stored as null, which means the team colour). It is set once, when the game learns the player's colour, and HostGame.pushMessage applies it to every message that player sends; the colour reaches the wire (Chat.push appends a fourth element) only when set.

Chat commands ​

CommandProcessor is a bare registry: chatCommands is the entire set a player can type, and one name may mean different things in two games. The four the engine used to own — /nr (initiateNewRound, guard it with ctx.isDevMode), /timeleft (ctx.timerManager.getMapTimeLeft()), /mapname (ctx.roundManager.currentMap) and /rank (ctx.playerDataSync.refreshPlacement(gameId, period), or getRating(participantId, period) for the value the last refresh left behind) — are game code now; the scaffold ships them in metaCommands. A handler gets (ctx, gameId, args), where ctx is { participants, chat, scripted, roundManager, voteCoordinator, timerManager, playerDataSync, teams, spectatorTeam, spectatorId, isDevMode }. Registering one name twice silently drops a handler. CommandProcessor neither awaits nor catches a handler, so an async handler must swallow its own failures — an unhandled rejection in the host Worker takes the room down. /changehost is reserved (see the table above). The engine has no API to rename a player — the nick comes from the player's identity.

System messages and votes ​

Chat system messages travel as codes group:index[:p0,p1]; the texts live on the client. The engine reserves the groups s, v, m, c and ships English defaults for every code in src/config/chatMessages.js (the full text table is in game-config.md); s:8 and v:6–v:15 are added by the client itself and never travel the wire. A game picks any other letter for its codes ({ BOTS_SPAWNED: 'g:0' }). Registration is a blind Object.assign into the engine registry: a colliding key overwrites an engine message without a warning. Never send a raw text array instead of a code — data goes in params (MAP_CURRENT with the map name, not [mapName]).

voteCoordinator (chat-command context) offers canCreateVote(voteCategory, gameId) → boolean and createVote({ voteName, voteCategory, payload, resultFunc, userList, gameId }): payload is { name, params?, values? } with name the client template key, resultFunc(result) runs when voting closes, userList restricts voters. canCreateVote is false while the category is on cooldown (timers.timeBlockedVote) or a vote of that category is live; one vote is active at a time and the rest queue; a tie is broken randomly; options are paginated 7 per page (keys 1–7, 8/9 page, 0 cancel). Reserved names: mapChange, teamChange and everything starting with @ (contract rule B9); reserved values shorthands: 'teams', 'maps'.

Panel and stat (host half) ​

gameConfig.panel is { fields: { health: { key: 'h', value: 100 } }, activeKey: 'wa' }: key is the short wire key, value the starting value that is also handed to the core as the initial resource amount (HP, ammo); activeKey names the active-weapon cell (null if unused). Runtime operations come from core events: set, decrement (default), increment; values floor at 0. The engine hardcodes one more cell, t — the round time in seconds — so the client panel schema must declare a type: 'time' field bound to t and the host schema must not define a field with key t.

gameConfig.stat is a map of columns { key, bodyMethod, headMethod?, headSync?, bodyValue?, headValue? }: key is the wire column index, bodyMethod/headMethod are '=' (replace), '+' (accumulate), '#' (count rows, head only), headSync: true recomputes the head cell when body rows change. The engine writes exactly five names: name, status, score, deaths, latency; an invented column is never written by the engine and an omitted one never appears. A scripted participant gets status: 'dead' like anyone else, but latency is updated from pong replies only — its cell keeps whatever text the bot manager passed to stat.addUser.

Player rank and state API ​

From onCoreEvent the vimp object exposes the per-(user, game) profile: addPlayerPoints(gameId, delta) (points of the current game), finishPlayerGame(gameId) (that game is over: it is summed into the month and maxed into the day), getPlayerRating(gameId, period) ({ value, placement, total } or null), isPlayerRatingLoaded(gameId, period), refreshPlayerPlacement(gameId, period) (a promise of the same shape), getPlayerState(gameId) / setPlayerState(gameId, state) (an opaque JSON blob that starts from gameConfig.playerState.defaultState), flushPlayerData({ urgent }) (a request for a sync) and overrideMapData(scaledMapData); period is 'day' | 'month' | 'all'. getPlayerRank/isPlayerRankLoaded still answer from the all slice; addPlayerRank is a deprecated alias of addPlayerPoints.

  • Points are not a rating until the game ends. RoundManager closes every participant's game at a map change and a round end; a game with neither (endlessRound plus geometry rebuilt through overrideMapData) names its own boundary and calls finishPlayerGame there.
  • getPlayerRating answers null for an unknown id and zeros for a known one whose slice has not arrived. A game that writes a rating into a stat column or compares against it must gate on isPlayerRatingLoaded, which is per slice.
  • { urgent: true } bypasses the write interval and the backoff; reserve it for a boundary a player is about to look at. The write budget is the engine's: a participant's result is written at most once per 5 minutes (±20 % jitter per room), a room sends at most 1 request per second, and after a server or network failure it backs off exponentially from 30 s up to 15 min.
  • overrideMapData is for a game that rebuilds its geometry on the fly instead of changing the map: pass the client shape (unscaled coordinates plus scale, the object broadcast with sendMap), because it replaces both copies the engine holds — the one _startRound places people on and the one a joining client is sent.

Kicks and technical messages ​

Kick codes: 4003 EMA latency above rtt.maxLatency, 4004 more than rtt.maxMissedPings unanswered pings, 4005 idle beyond idleKickTimeout.<role>, 4006 room full. The host's own client (socket 'local') is immune; there is no kick vote. The client's techInformList is indexed: 0 fullServer, 1 anotherDevice, 2 loading, 3 kickIdle, 4 kickForMaxLatency, 5 kickForMissedPings, 6 roomFull.

There are no bots in the engine — only the neutral notion of a scripted participant (isScripted getter; the word "bot" does not survive in engine code). The engine policy "scripted participants give up their slot to humans" stays generic.

ClientPlugin API ​

Default export of the game's client entry.

js
export default {
  id: 'tanks',
  engineApi: 4,
  async createClientCore(clientConfigJson, { wasmUrl }) { /* init(wasmUrl); return { core, memory } */ },
  parts:  { Map, MapRadar, Tank, TankRadar, Bomb, ExplosionEffect, Smoke, Tracks, ShotEffect },
  bakers: { explosionTexture, …, trackMarkTexture },
  styles: '…css…',                    // game CSS (panel weapon sprites etc.)
  hooks: {
    onAuth(core, authData)   { core.set_model(authData.model); },
    onPanel(core, panelData) { core.sync_panel(JSON.stringify(panelData)); },
    onLocalAction(core, action, name, now) { /* try_fire / cycle_weapon; → spawn JSON | null */ },
    services(core) { return { mapDynamics: /* … */ }; },  // optional
  },

  serviceNames: ['mapDynamics'],   // optional: what services() returns
};

hooks.services(core) is optional: it returns game services that are merged into the client's service pool next to the engine's own (renderer, soundManager, localPlayer, assetsBase, accolades, diagnostics) and reach a part through componentDependencies. That is how a part talks to the game's own core without the engine knowing what is being handed over — the tanks plugin serves mapDynamics (toWorld(key, localX, localY) over ClientCore.map_dynamics_to_world) so the shot effect can anchor its debris to the box it hit. Engine keys win a name clash.

The diagnostics service ​

An engine pool service that puts a game's own warnings and caught errors into the client error journal. A part asks for it like any other service:

js
// componentDependencies: { diagnostics: ['Map'] }
constructor(data, assets, dependencies) {
  this._diagnostics = dependencies.diagnostics ?? null;
}

draw() {
  if (!this._camera) {
    this._diagnostics?.warn('mygame.camera.missing', { level: this._level });
  }
}
  • warn(code, details) — a warning of the game: code matches /^[a-z0-9][a-z0-9._-]{0,63}$/i (prefix it with the game id; anything else is recorded as invalid-code), details is a plain JSON object up to 2048 bytes (larger becomes { truncated: true }). The call site's stack is attached.
  • capture(error) — an error the part caught itself and still wants seen.

Both are cheap and never throw: repeats are only counted, and the per-session limits of the journal apply. Do not list diagnostics in requires: the service is optional, and on an engine without it the part simply gets undefined — hence the ?. above. The engine capability diagnostics exists only so the registry records when the service appeared.

serviceNames is the same list written down statically. Nothing at runtime reads it: it exists for the contract checker, which cannot call services(core) (that needs a live core) and therefore cannot tell a game service from a typo in componentDependencies. Without the list rule C4 reports an unknown name as a warning; with it, the name is an error again — and an unprovided service is silently undefined in the part.

ClientPlugin: obligations ​

  • createClientCore must return { core, memory }. memory is the WebAssembly memory object the engine reads the hot buffer out of every render tick; the Node build (wasmUrl ending in .js) exposes none, so it returns memory: null and the headless client reads the buffer by copy (hot_values()).
  • hooks.onAuth, hooks.onPanel and hooks.onLocalAction are called unconditionally: a no-op body is fine, a missing hook is a crash.
  • styles is a CSS string (import it with ?inline: the plugin build has no HTML entry, so Vite must not auto-inject a <style>).
  • There is no views field.

Parts ​

A part is a class the engine instantiates for one snapshot entity on one canvas: constructor(data, assets, dependencies, context) (first-frame values), update(data) (later frames), destroy() (the entity disappeared). data is the entity's field array; assets the baked assets of this class; dependencies the services it declared; context is { id } — the entity's id as it appears in the frame (a string, null for an effect). A part that draws must be, or contain, a Pixi Container and add itself to the stage — the engine does not.

The map part is the exception: its static instances are built by applyMapData from MAP_DATA, one per render layer per map level, under the running keys s0..sN, each with { type: 'static', spriteSheet, map, step, layer, tiles, physicsStatic, scale } plus, for layered maps, level (0 = ground), solid and floor. A single-level map yields one instance per layers entry, all with level: 0 (core.md).

Effects. When a snapshot payload for a key is an array of records, each record creates a short-lived effect instance. An effect class also implements run(); the engine wraps its destroy() so that it removes the effect from the registry, and the effect destroys itself when its animation ends.

Registration. parts.gameSets maps a snapshot key (or a map setId) to the part classes built for it; parts.entitiesOnCanvas maps a class to its canvas. Only classes listed in entitiesOnCanvas reach the factory — a class in gameSets but not there throws Constructor for X not found. on the first frame. A frame key with no gameSets entry throws too, so every snapshot key needs a set, even with one class. One entity may appear on several canvases by listing several classes (Tank on the main canvas, TankRadar on the radar). Contract rule C2 checks both.

Draw order. PixiJS v8's stage.sortChildren() takes no comparator: it sorts by zIndex. The engine sets stage.sortableChildren = true and sorts after every addChild, so a zIndex assigned in a part's constructor works, while a bare layer property does nothing. Parts that never touch zIndex share 0 and are painted in insertion order, which the order of classes in gameSets influences.

Bakers ​

bakedAssets is { canvas: [{ name, component, params }] }; each name points at a function in bakers: (params, renderer) → Texture | dict of Textures. A baker runs once per canvas at startup, before any part exists; the result lands in the assets argument of every instance of the component class, keyed by name. A baker whose name has no entry in bakers is silently skipped. A baker owns what it returns: re-baking (WebGL context restore) destroys the previous result with its TextureSource, so never return a view onto a shared atlas or a texture someone else also holds.

Services ​

The pool is an append-only registry (a game adds its own through hooks.services(core)); a name never disappears and a part gets only what it asked for in componentDependencies, so a service added later demands nothing of an older game.

ServiceValueUsed for
rendererthe canvas's Pixi renderergenerateTexture, baking a map into one sprite
soundManagerthe engine's SoundManagerregistering positional voices
assetsBasethe active game's asset base, a stringURLs into the game's own package: ${assetsBase}img/<file>
localPlayer{ id, is(id) }telling the local player's entity from everyone else's
accolades{ placeOf(id), boardOf(period), selfOf(id, period) }the entity's place in the game's global top
diagnostics{ warn(code, details), capture(error) }the client error journal; optional, may be undefined

Requesting anything else yields nothing: the key is silently absent from dependencies. A part depending on assetsBase should check it and log a readable console.error — ${undefined}img/tiles.png loads nothing and reports nothing. Log, never throw: part constructors run inside the render tick and nothing on that path catches, so an exception aborts the whole frame.

localPlayer. id is the client's own game id, or null until the first player block; is(id) compares as strings (frame ids are object keys). Ask at the moment of need, not in the constructor: entities are created from FIRST_SHOT_DATA, before the first binary frame, so the local player's own part is built while localPlayer.id is still null. The typical use is sound — a cue that belongs to the player is registered only when this._isLocal(), the visual half stays for every entity.

accolades. placeOf(id) returns { daily, monthly }, each a number or null (bots, guests and unknown ids give null; it always returns an object); boardOf('day' | 'month') the top rows { place, nick, score }; selfOf(id, period) { place, score } or null. The room asks the platform, never the player: everything arrives in one ACCOLADES_DATA broadcast from the host, and a client issues no request of its own. Places are matched to participants by nickname from the public leaderboard and are handed only to a participant with a verified identity — a guest nickname is spoofable. Ask at the moment you draw: the first broadcast can arrive long after the part was built. The engine decides nothing about what a place looks like — the part draws the badge.

Client auth screen ​

authSchema on the client side is { elems, texts, params, validators } (the wire form is in snapshot.md). The id the engine reads for the field container is fieldsId (not formId); texts.title renders into #logo; validators are functions and are not serialised — they run on the host; storage: '<key>' persists the field in localStorage. There is no nickname field: identity comes from the lobby JWT and the host reads its nick claim (nicknames are validated globally against ^[a-zA-Z]([\w\s#]{0,13})[\w]{1}$).

Key point: the Stat/Panel/Vote/Chat modules are engine-owned but fully parameterized by the game's config. Consequences:

Engine moduleWhat the game supplies (via CONFIG_DATA / gameConfig)
Panel (host + client MVC)field schema (fields + display types: bar/number/time/weapon icon), activeKey; the engine PanelView generates DOM from the schema , field appearance — game CSS
Stat (host + client MVC)columns (names/aggregation) and a team list of arbitrary length; the engine StatView generates tables by team count
Vote (host + client MVC)game votes created dynamically (voteCoordinator.createVote from chat-command handlers) + all templates/menus (texts); engine-mechanism votes (teamChange, mapChangeByUser/BySystem) stay in the engine, and vote names starting with @ are reserved for the engine (@changeHost — the platform's "Change host?" window, lobby mode), their texts also come from the game
Chat (host + client MVC)game system-message codes (the b:* group and future ones) + the texts of those codes; the engine owns the mechanism, its own codes (s/v/m/c) and their English default texts (src/config/chatMessages.js) — a game may override any of them by index ('' silences one)
CommandProcessorEVERY chat command: the engine parses none of its own, the game fills the whole registry (/bot, /nr, /timeleft, /mapname, /rank). Exception: in lobby mode the client intercepts /changehost (the platform's "Change host" vote) before it reaches the host — a game must not declare a command of that name
RoundManager / ParticipantManagerteams (arbitrary), spectatorTeam, respawns from maps, scripted parameters; the engine has the neutral "scripted participant"
SocketManagersoundCues (which sound plays on which engine event), initialVote
SoundManager (client)sound list + files (assetsBase)
Controls (client)the player keyset and layout; the spectator set is engine-owned
Authform schema (authSchema) + model validator

There is no views schema-bypass export: ClientPlugin has no field for supplying a custom Panel/Stat view class, and no plugin-loading code validates or reads one — the engine's own schema-generated PanelView/ StatView are the only implementation today. Radial/canvas indicators are still possible without it: a HUD entity on canvas is a regular part.

Wasm Host ABI (v1) ​

The #[wasm_bindgen] GameCore/ClientCore wrappers live in the game crate (wasm-bindgen cannot export generics), but the mandatory method set is fixed by the engine (part of engineApi) — engine JS calls those methods. Principle: the hot path carries no JSON (scalars + zero-copy pointers); JSON is for the constructor/map/events/rare queries.

The delegation boilerplate (40 methods across two classes) is removed by the engine macros export_game_core_abi!($Sim) / export_client_core_abi!($Client) (macro_rules! in vimp-engine-core — the single source of truth of the mandatory set, drift is impossible): the game crate invokes them next to its extra methods (try_fire, set_model, sync_panel, custom spawn_actor arguments). Expansion happens in the game crate, so #[wasm_bindgen]/JsError resolve against its dependencies — the engine crate still does not depend on wasm-bindgen.

GameCore — participant lifecycle methods: spawn_actor/remove_actor/reset_actor (human), spawn_scripted_actor/remove_scripted_actor (scripted, formerly spawn_tank/remove_tank/reset_tank/add_bot/remove_bot). Unchanged: new(configJson) (format {engine:{timeStep,seed,snapshot,mapScale,mapSetId}, game:{models,weapons,panel,playerKeys,friendlyFire}}), load_map, map_info, apply_input, apply_aim (pointer input), step, take_events, pack_body, pack_frame, body_has_events, frame_ptr/frame_bytes, is_alive, position_of, players_data, alive_players, last_input_seq, reset_all_vitals, remove_players_and_shots, clear, serialize_state/deserialize_state, debug_json (a curated world dump for debugging — see debugging.md; generated by the macro, so a game implements nothing).

Standard take_events dictionary (removes the game vocabulary from GameCoreAdapter._drainEvents):

jsonc
[{ "type": "panelSet",    "id": 3, "field": "health", "value": 55 },   // field — a panel-schema field name
 { "type": "panelActive", "id": 3, "field": "w2" },
 { "type": "death",       "victim": 3, "killer": 1 },
 { "type": "shake",       "id": 3, "intensity": 20, "duration": 200 },
 { "type": "custom",      "data": {…} }]                                // → HostPlugin.onCoreEvent

ClientCore — engine minimum: new, push_frame, my_game_id, offset, sample, hot_ptr/hot_values, take_frames, apply_input, apply_aim (pointer input), set_active, set_map, reset, resync (drops the network half only — buffer and frame queue — after a long tab pause, keeping prediction and identity), decode_frame, plus the debugging pair debug_json and take_divergence (also macro-generated). Game methods (set_model, try_fire, cycle_weapon, sync_panel) are not part of the minimum — only ClientPlugin hooks call them; a game reaches its own half through ClientState::game() when its ABI wrapper exposes something of its own (the tanks plugin serves the geometry of the predicted map dynamics that way).

The set_map payload the engine hands to the core is {map, step, scale, setId, physicsStatic, physicsDynamic, levels, ramps, levelHeight, game} — the raw MAP_DATA fields, unscaled (the core scales them itself). levels and ramps are the layered (2.5D) map format and are optional: a map without them is exactly the single-level map the engine has always loaded. levels is keyed by the level number as a string ("1" — the first above-ground level; level 0 stays in map/physicsStatic/layers), and each value is {map, floor, walls, layers} — a grid of the same dimensions as map, the tiles that can be driven on, the tiles that block movement (railings), and the render layers of that grid. ramps is a list of {tile, dir, from, to} — the directed tile transitions between levels (dir — the direction of the climb: north/south/west/east). A respawn point may carry a fourth number, its level ([x, y, angle, level]), and a physicsDynamic object a level field; both default to the ground. The whole format is validated in the core (MapConfig::validate) and, before the build, by contract rule E4. A game that cannot run without it declares requires: ['map.layers']; a game that needs more than two levels declares map.levelsN as well.

levelHeight is the height of ONE level in world units (unscaled, like step — the core multiplies it by the map scale itself). It is optional and defaults to the tile size, so a climb of one level per tile is a slope of 1.0. It exists because everything but the level number is measured in world pixels: without it RampSample::slope would be «levels per pixel» and every constant tuned for a dimensionless gradient (climb thrust, hull pitch, dust) would miss by two orders of magnitude. The value has to be finite and greater than 0 — checked in the core and by rule E4. A game that needs it declares requires: ['map.levelHeight'].

game is the map's opaque game data — an object or absent, as is physicsDynamic[i].game. The engine neither reads nor scales it: put coordinates in grid cells, or in unscaled units the game multiplies by scale itself. It reaches the host core (GameMap::game_data(), dynamic_game_data(i), read in GameSim::on_map_loaded), the client core through set_map, every static map part (game) and every dynamic part d{i} (the object's own keys, game included). The shape is checked in the core and by rule E7. A game that needs it declares requires: ['map.gameData']. The state byte of a map body — role: 'state' in the dynamics row, SimCtx::map_body_state in the core, game access to map bodies by index — is requires: ['map.bodyState']; see snapshot.md.

The run of a ramp is closed by physics, not by rules alone: the engine builds guard colliders along both sides of every run and across its far end (RAMP_GUARD_GROUP). A body standing on the level the run starts from sees them (it cannot drive onto the wedge from the side or under it from the wrong end), a body legally climbing the run does not (levels_interaction_on_ramp), and a body of another level never meets them at all. The foot of the run is never closed — that is the legal way in. A wide ramp is cut into parallel lane runs, but the guards fence the block of lanes, not every lane: only the outer sides of the block get a guard, and its far end gets one across the full width. Lanes of one block share RampRun::block, the number a game compares to tell a lane change from a new ramp; a guard per lane would turn a wide ramp into a set of tile-wide troughs and stop a body entering along a lane border, since it is not climbing yet. Cells of two different ramps may not overlap: the run that claims a shared cell would be picked arbitrarily.

Up to MAX_LEVELS (8) levels are supported — the ground plus seven overhead ones, one collision bit each — and a ramp may span more than one of them. Every level (the ground included) may carry volumes, an optional { "<layers key>": height } map: the visual height of that render layer in levels. The core does not use it — it travels to the client and is built by the game's renderer — but it is validated on both sides: the key has to name a render layer of the same level, the height has to be within (0, 8]. The dynamic bodies of the map now have level rules of their own: a body that runs out of floor under it falls to the nearest slab below (landing_level), one trajectory (FallModel, gameConfig.mapFallTime, 0.35 s per level by default) shared with whatever the game drops.

On the client the same levels reach the render parts: applyMapData builds the static data per level and hands each part instance its level, solid (blocking tiles), floor, volume (the height of that layer from volumes, 0 when flat), levelHeight (world units per level, already scaled), ramps (the ramp configs of that level, so the part can draw the wedge over the grid it already has) and game (the map's game, as declared) — see client-api.md. setId is the snapshot key the map's dynamics travels under (c1/c2): without it a game cannot tell its own dynamics block from another map constructor's.

Two optional GameClientDef trait methods sharpen the prediction divergence detector and default to None, so a plugin may ignore them entirely: predicted_state() -> Option<[f32; PLAYER_STATE_LEN]> (the predicted state in the player-block layout, compared component-wise against the authoritative frame) and replayed_inputs() -> Option<(f64, f64, usize)> (the input-history window the last reconciliation replayed). Without them the engine falls back to comparing the render_overlay() camera with state[0]/state[1] of the frame's player block — which is a contract on the layout: if the first two components are not world x/y, implement predicted_state(), otherwise that fallback reports meaningless violations. See debugging.md.

The export table is frozen ​

The method set above is frozen forever: a published .wasm cannot grow its export table, so new engine capabilities reach a core as opcodes that the core declares itself through the macro-generated abi_describe()/dispatch() methods — an opcode the core did not declare is simply never asked for. A method may stop being called by the engine, but is never renamed or reshaped.

Snapshot blocks — a declarative schema ​

Fixed block layouts are replaced by a schema: SnapshotConfig.keys maps each key to a BlockSchema of five fields — id (the block's opcode in the frame), kind (BlockKind: the row shape, which is what implies the count/id widths and whether rows carry a null marker), class (hot — interpolated / event — frame-only), fields (each with a type f32/u8/u16/u32, an interpolation mode lerp/lerpAngle/discrete and an optional engine role: z/level/state in the map dynamics row) and the optional optionalFrom (the index of the first field of the row's optional tail: those fields are written only when the row carries them, and a flag byte in front of the row says whether they follow — see snapshot.md; decoding always yields a full-width row, a missing tail reads as zeros). The d prefix on indexedNoNull8 ids is likewise not a schema field — it is hardcoded in the decoders. The packer (snapshot.rs), unpacker (client/unpack.rs), interpolator and the engine hot buffer are schema interpreters; the game crate supplies rows as flat Vec<FieldValue>. The schema itself is game data — the game plugin's src/config/snapshot.js (e.g. @vimp-games/tanks's) (HostPlugin.gameConfig.snapshot, a required field). The same schema travels to client JS in CONFIG_DATA → a generic reconstructHot (record width = 2 service fields + the key's fields count); the engine bundle carries no snapshot keys (the host always provides the schema — no hidden "client bundle must match the host" coupling). The player block is not schema-described: its layout is fixed by the engine (PLAYER_STATE_LEN = 8 f32s + a centering flag) and hardcoded in both the packer and the decoder. (gameConfig.playerState is an unrelated key — the starting rank/state profile blob.) SNAPSHOT_FORMAT_VERSION stays 5 (engine framing; the byte layout did not change); byte compatibility across deploys is not required (host and clients are one deploy; the version only protects framing within a room).

Invariants to keep: gameId in a frame is a u8 (≤255 participants); the weapons key order defines the weapon index; panel fields must correspond to weapons keys (validated in new GameCore).

CONFIG_DATA (port 0) ​

Stays an engine mechanism; assembled by buildClientConfig as a merge: engine defaults (clientDefaults: interpolation, controls.modes/cmds, elems structure, techInformList) + HostPlugin.buildClientGameConfig() (parts.gameSets/entitiesOnCanvas/bakedAssets/componentDependencies/sounds, keySetList, panel/stat schemas, chat/vote/gameInform texts, prediction: models/weapons/playerKeys/timeStep) + the snapshot schema + room-derived values (voteTime). initIdList and the canvas list come from config, not from hardcode.

Rust traits (vimp-engine-core) ​

The engine crate is pure Rust without wasm-bindgen (errors are Result<_, String>; the game crate maps them to JsError). Static generic dispatch: EngineSim<TanksGame> / ClientState<TanksClient> are monomorphized — zero overhead at 120 Hz; no dyn needed (one wasm bundle = one game).

  • trait GameDef { type Config; type Sim: GameSim<Self>; }
  • trait GameSim<G: GameDef>: new, spawn_actor, remove_actor, reset_actor, reset_all_vitals, spawn_scripted_actor, remove_scripted_actor, apply_input, apply_aim (pointer input, default empty), last_input_seq, is_alive, actor_position, prediction_state, alive_players_flat, players_json, on_fixed_step(ctx: &mut SimCtx, dt), on_contacts(ctx: &mut SimCtx, pairs), on_before_destroy, on_ai_tick(ctx: &mut SimCtx, dt), refresh_cached, build_snapshot_blocks(&mut self) -> (Vec<(String, Block)>, has_events), remove_players_and_shots, clear, serialize/deserialize (mid-round handoff — kept as groundwork), rebuild_spatial_grid, on_map_loaded(ctx: &mut SimCtx) -> Result<(), String> (default Ok(()): every map load, not on deserialize_state; an error leaves the world without a map).
  • SimCtx<'a> (not generic over the game) — the game's access to engine facilities passed to the tick callbacks: world: &'a mut PhysicsWorld, cfg: &'a EngineConfig, map: &'a Option<GameMap> (respawns), nav/ spatial: &'a Option<NavigationSystem>/&'a mut SpatialGrid (A*/grid — engine utilities in a nav/ module, no "bot" wording), rng: &'a mut Rng, events: &'a mut Vec<CoreEvent>, bodies_to_destroy: &'a mut Vec<RigidBodyHandle>, map_body_state: &'a mut [u8] (one state byte per map dynamic body). No game_cfg field — the game config is only handed to GameSim::new; the game stores what it needs itself.
  • The engine owns: the fixed-step accumulator, contact collection, the destroy queue, the schema-driven SnapshotPacker, the handoff skeleton, CoreEvent.
  • The client half: trait GameClientDef { type Config; fn new(cfg, engine_cfg); fn on_server_state(state, centering, server_time, offset, local_now); fn update(local_now); fn track_frame(my_game_id, frame); fn filter_frame_game(game, my_game_id, local_now); fn update_world(snapshot); fn update_world_interpolated(game); fn render_overlay(my_game_id) -> Option<RenderOverlay>; fn apply_input(...); fn apply_aim(...) (pointer input, default empty); fn set_model(...); fn set_active(...); fn set_map(...); fn sync_panel(...); fn reset(); fn cycle_item(back); fn try_action(...); fn begin_reconcile(snapshot) / fn finish_reconcile() (the authoritative frame before the replay and the divergence after it — default empty); fn render_rows() -> Vec<PredictedRow> (rows for bodies the game predicts itself — default empty) }, plus the two optional methods below. The engine provides the Interpolator (schema-driven), the generic ClientState<G> orchestration (network buffer, event-frame queue, render-tick hot buffer), the hot buffer, raycast. Actor prediction, visual spawning and the panel are entirely the game's own responsibility inside its GameClientDef implementation.

Profile writes: rank & skills ​

Any game registered on the platform (see submitting.md) may write to a player's shared profile. Every write is namespaced by the game id; a game only ever touches its own namespace, never another game's.

  • rank — a single format shared by every game: one clamped integer per (user, game) in the range 0–1000000, rendered identically wherever the platform shows it (e.g. the lobby). This is part of the public SDK contract. HostPlugin reports a match delta, not an absolute value — the platform owns the ledger and the clamp.
  • skills — an opaque JSON state, format entirely up to the game. The engine never reads or validates its fields, only its overall size (at most 8192 bytes); the SDK contract covers only the namespacing mechanism, not the schema.
  • A game never writes nick or identity — those come from the player's identity token, not from game code.
  • Both writes go through the host under the reporting player's own identity and are attributed to the reporting room.

Compatibility invariants ​

The engine never deletes or renames a name a game can write or read (a gameConfig field, a service name, a form control, a wasm-ABI method, a port number, a snapshot block key), never makes anything new mandatory, never changes the shape of existing data, and never rejects a plugin that loaded before. Rust traits grow only by methods with a default implementation. A plugin of any age therefore stays valid; the source API of vimp-engine-core may change only for games that choose to rebuild.

requires and engine capabilities ​

GameManifest.requires is an optional array of engine capability names the game cannot run without:

jsonc
{
  "id": "tanks",
  "requires": ["accolades"], // optional; absent = nothing beyond the base contract
}

The engine answers with a verdict, not with a version comparison (checkPluginCompatibility): every name is looked up in the append-only capability registry capabilities, and a name the registry does not know means the game is newer than the engine. Currently registered: stat.leaderboard (rank-period leaderboard), accolades (ACCOLADES_DATA port and the client service), dispatch (dispatch / abi_describe in the core), map.layers (layered 2.5D maps: levels / ramps in the map format, level masks in physics, set_actor_level, the layered navigation graph) and map.levelsN (more than two levels, ramps that span several of them, level rules and falling for map bodies, z / level in the dynamic row, volumes) and map.levelHeight (the map's levelHeight, a dimensionless ramp slope, ramp guard colliders, levelHeight/ramps in the part context), map.gameData (the map's opaque game field in the core, set_map and the part context; the core's on_map_loaded hook), map.bodyState (the map body state byte, role: 'state', and game access to map bodies by index), diagnostics (the client error journal service; optional, never worth a requires entry) and host.migration (host checkpoints: gameConfig.migration, module hooks serializeState/restoreState; never worth a requires entry either). A registered name is supported forever — a published game may have written it, and its dist/ will never be touched again.

Declare a capability only when the game breaks without it. A game that degrades gracefully — does not ask for the service, is not subscribed to the port — needs no requires: engine defaults (gameConfigView) and the append-only vocabularies accept it as is.

HostPlugin.requires and ClientPlugin.requires are the same optional array on the plugin halves. The standalone SDK reads them: in solo mode there is no platform, so there is no manifest to read requires from. startStandaloneGame({ requires }) overrides both halves when the embedding code knows better.

The three lists must agree: a game whose manifest asks for a capability its halves do not is refused by the lobby and accepted by the SDK, which then plays a silently reduced match — the exact failure mode capabilities exist to replace. Contract rule B2 refuses the mismatch, which is where a game author meets it, before publishing. loadGamePackage only warns: the Node paths read the authoritative manifest, whose compatibility is already checked, so a package that loaded before must keep loading (I4). Names are compared resolved through the capability registry, so a manifest on a new name and a half still on its retired alias agree. A half that declares no requires at all is exempt; requires: [] is not — it is a claim that the game needs nothing.

requires must be an array of strings. Any other shape (a bare string, an object, a non-string element) is a broken manifest: the verdict is {ok: false, reason: 'bad-manifest'}, treated exactly like an incompatible game rather than crashing whoever read it. That holds for the halves too: the SDK merges them with mergeRequires, which never spreads an untrusted value — a bare string would otherwise decay into a list of single letters.

Where the verdict lands:

Entry pointOn a missing capability
the platform's catalogthe game stays in the catalog flagged incompatible; the lobby shows it disabled with the reason, and no room can be created for it
loadGamePackage (dedicated, vimp-sim, inline host)throws — there is only one game and nothing to fall back to
loadClientPlugin (browser)throws before the plugin bundle is imported
standalone SDKthrows — one check over the union of both halves' requires

Every message names the side to update: "game "x" needs engine capabilities this build does not have: … — update the engine".

Versions and compatibility ​

ConstantOwnerPolicy
ENGINE_API_VERSION (=4)enginefrozen at 4 and never bumped again; a generation label carried by every manifest and plugin half, not a compatibility gate. Its history: v2 introduced the explicit Form schema (roomForm, authSchema.params[].options) in place of type-based control inference; v3 cut the control set down to native elements (range/number/toggle/segmented are back as permanent aliases); v4 added the ACCOLADES_DATA port, the accolades service and the leaderboard mode of the stat module. The engine keeps every retired name working, so a game built against v2 runs on today's build unchanged. Contract rule B2 only requires that the manifest and both plugin halves agree with each other and that the value is imported, not hardcoded
SNAPSHOT_FORMAT_VERSION (=5)engine (framing)the block schema travels in CONFIG_DATA → always consistent within a room

← Previous: Standalone SDK (browser) · Next: Game configuration reference →

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