Skip to content

Client runtime for game authors ​

What a game plugin meets in the client: the single shared PixiJS instance, the dispatcher and render loop that call into the plugin, the engine components a game drives through its config and snapshot data, the client core (ClientCore), rendering through parts/, Factory and the providers, the sound manager, input and the UI layers.

Single shared PixiJS instance ​

The engine provides PixiJS to the page through the browser's import map. A game plugin must declare pixi.js as an external peerDependency (and in its bundler's external list) and never bundle its own copy: two independent copies mean two PixiJS renderer registries, which crashes rendering. Keep the plugin's peerDependencies range for pixi.js aligned with the version the installed vimp-engine ships.

Dispatcher and render loop ​

What a plugin can rely on while the client draws a frame and after a map change or a lost context.

  • The render loop: every animation frame the engine calls clientCore.sample(now), reads the flat hot buffer zero-copy from WASM memory in full, before any other call into the core, then take_frames() for rare event frames, and applies the result in the order frames → hot data → camera (see "Client Core" below).
  • Resets: a map change (MAP_DATA → set_map) and CLEAR (→ reset) clear the frame buffer and the predictor in the core; reset also drops the local player's identity (my_game_id), so the prediction overlay stops rendering an entity the host no longer has.
  • The map hands the payload to the client core (set_map) and assembles the static render data. The core gets the layered fields as they arrived — levels (above-ground levels: map, floor, walls, layers) and ramps — so that it builds the same geometry as the host; without them its level prediction would drift from the authoritative one in silence. The render data is assembled per level: level 0 from layers over the map grid, every above-ground level from levels[n].layers over the levels[n].map grid. The keys s0..sN run across all levels (they are part-instance ids in GameModel — two s0s would overwrite each other and half the map would never appear), and each instance receives level (its level number), solid (the tiles that block movement on it — physicsStatic for level 0, levels[n].walls above) and floor (levels[n].floor, empty on level 0) next to map, step, layer, tiles, physicsStatic, scale and spriteSheet it already read. A map without levels produces exactly what it produced before.
  • Tab wake-up: after a pause of at least 3 s the engine calls the optional clientCore.resync() (an older core without it is fine); a resync drops the frame buffer, including event frames, so it is not done after a short alt-tab.
  • WebGL context loss: when every canvas has a live context again, the engine removes all controllers (they hold dead textures), re-bakes the assets, rebuilds the map from the cached MAP_DATA and restarts the render loop; parts must not keep textures across that rebuild.

Game-facing components ​

The engine components a game drives through its config and snapshot data. The lobby screens (LobbyAuth, Profile, Lobby) belong to the platform and are not part of the plugin contract.

  • Auth — the per-room login form for game-specific fields only (e.g. model), with client-side validation and localStorage; its fields are built from PS_AUTH_DATA.params[] — see plugin-api.md. The nick is not typed here: it comes from the user's verified identity.
  • CanvasManager — manages several PixiJS Applications at once: vimp (the main game canvas) and radar (the mini-map); the canvas elements are generated by the client from the game's canvases config (modules.canvasManager.canvases, including the initial width/height) — they're not in the HTML. Adaptive scaling (a 1920px reference width), aspectRatio/fixSize/baseScale, a dynamic camera (look-ahead, speed-based zoom), and shake — parameters in game-config.md. The camera frame only moves the scene; the canvas is drawn once per tick, after the frame is applied in full, so a part's onRender never sees a half-applied frame. Positions are measured in renderer.screen units (not canvas buffer pixels), so a game that reconstructs the camera centre from the scene transform (e.g. the 2.5D height projection in @vimp-games/tanks) stays correct at any resolution.
  • Controls — keyboard capture (InputListener), the active key set dictated by the server (port 17), chat/vote/stat modes, input sent as "seq:action:name". Optionally a pointer channel as well (mouse, finger, stylus — one set of Pointer Events): declared by the game as modules.controls.pointer, it sends "seq:aim:x:y:flags" with a world point and a bit mask — bit 0 «pressed», bit 1 «double tap». It obeys the same gates as the keys: input disabled, an open mode or a key set outside pointer.keySets mutes it and releases a held pointer. A game that does not declare pointer gets no listener and no traffic — see InputListener.
  • Game — the rendering core: GameCtrl.parse(name, data) creates/ updates/removes entity instances from snapshot data through Factory.
  • Chat — message output (row/lifetime limits), the command line; escaping happens on output (textContent); system messages use the engine's English defaults from config/chatMessages.js unless the game overrides them.
  • Panel — the HUD: round time, health, ammo, active weapon (from 'key:value' strings). PanelView generates the DOM from the game's schema (modules.panel.fields: an ordered list of { name, elem, type }; cell semantics come from type: 'bar' | 'value' | 'time' | 'weapon', not from field names — a bar field also takes max and blocks) inside the engine's #panel container; the cells' look is the game's CSS (bar blocks use the engine-neutral panel-bar-* classes). The #logo header inside #panel shows the game's title from authSchema.texts.title (same value as #auth-title, applied when PS_AUTH_DATA arrives, falling back to 'VIMP' before that / if absent); #panel/#logo CSS is flex-based so the panel table reflows around titles of any length.
  • Stat — sortable scoreboard tables (sortList), shown on Tab. StatView generates the header and tables from the game's schema (modules.stat.params: columns — column labels, bodies — an arbitrary number of teams) inside the #stat container; team colors/labels are the game's CSS.
  • Vote — vote windows built from templates, pagination, a lifetime timer. Engine votes (a name starting with @, reserved — today only @changeHost) come from the platform, not the host; an engine vote and a host vote never overwrite each other — whichever comes second waits in a queue.

Client Core (ClientCore) ​

Client-side math — snapshot interpolation, the local tank's prediction, visual shot spawning, and v3 frame decoding — lives in the Rust core (vimp-engine-core/src/client/ + the game plugin's own core/src/client/, e.g. @vimp-games/tanks's, the wasm-bindgen class ClientCore from the same WASM binary as the host's GameCore). The JS shell only forwards data and applies the result to rendering; ABI and layouts — core.md.

What the engine does with the core:

  • Frames and ports. A binary frame goes in through push_frame(bytes, now); the core decodes it (a version mismatch drops the frame), inserts it into the buffer by seq with deduplication and, if the frame carries a player block, reconciles the predictor. MAP_DATA/PANEL_DATA/KEYSET_DATA/CLEAR mirror into set_map/sync_panel/set_active/reset; the model is set with set_model on auth. reset means "the world is gone": it clears the buffer, the predictor and my_game_id (restored from the next player block; a spectator has none).
  • resync() (optional) clears the network half only — the interpolation buffer and the outgoing frame queue — keeping prediction and identity. The engine calls it when a tab becomes visible again after a long pause.
  • Every render tick. sample(now) returns the length of the flat hot buffer, read zero-copy through hot_ptr() and a Float32Array over the Wasm memory. The view is valid only until the memory grows, so read what you need before any other call into the core. The buffer carries flags, the camera (already resolved), interpolated records and, last, the game's predicted records: the local actor's (render_overlay) and any bodies the game predicts itself (render_rows). The engine turns it into { key: { id: [...] } } for the parts.
  • Event frames. take_frames() returns a JSON array [{ game, camera }, …]: every crossed frame emitted exactly once (events, creations/removals, camera reset/shake), with duplicates of own shots already suppressed. Sound and effects fire from the parts on entity creation.
  • Input. apply_input(action, name, now) records the predictor history; apply_aim(x, y, flags, now) records the pointer (a default empty implementation, so a core that ignores the pointer needs no change). Game actions go through ClientPlugin.hooks.onLocalAction (e.g. try_fire, cycle_weapon); sending "seq:action:name" to the host is unchanged.

The game's ClientPlugin (the game plugin's index, e.g. @vimp-games/tanks's; loaded dynamically by the engine from the manifest's entries.client — gamePlugin) supplies parts (entity renderers), bakers (procedural textures), the game CSS and the hooks. The core's game methods are called only from its hooks — onAuth (set_model on auth), onPanel (sync_panel per panel frame), onLocalAction (e.g. try_fire/cycle_weapon in @vimp-games/tanks); the client shell doesn't know the core's game methods. The game's CSS (panel cells, canvases, team colors) is the game plugin's own src/client/*.css (e.g. @vimp-games/tanks's tanks.css); the engine UI skeleton is vimp-engine/src/client/style.css.

What the core provides, in short:

  • Interpolation (vimp-engine-core): the world renders at serverNow − interpolation.delay (default 100 ms); positions are lerped (angles by the shortest path), discrete fields come from the reference frame, there is no extrapolation, and the events of a late frame are emitted at once.
  • Prediction (your core, client/predictor.rs): a replica of the authoritative motion without physics-engine collisions, at a fixed timeStep; the tick formulas are shared with your core's actor-update code and a cargo parity suite (client::predictor::parity) locks in integration parity. It keeps the input history, replays from the frame's serverTime, decays the visible error exponentially (with a snap) and resets on a camera force-reset, a map change or a key set change.
  • Shot spawning (your core, client/shot.rs): a replica of the authoritative gate and muzzle formulas, a raycast over wall tiles plus an OBB test, a single pending-projectile gate, an RTT-compensated position and suppression of authoritative duplicates by author id; field names and exact gating are game-defined (see snapshot.md). Client-only visual randomness (e.g. tracer spread) is purely visual — the authoritative entity arrives in a frame.

Rendering ​

parts/ — entities ​

The game plugin's own src/client/parts/ (e.g. @vimp-games/tanks's) — classes rendered on the PixiJS canvases, one per game entity type (e.g. @vimp-games/tanks's Tank, TankRadar, Map, MapRadar, Bomb, Smoke, Tracks). Effects follow the same plugin-owned convention (e.g. @vimp-games/tanks's parts/effects/), animated on Ticker.shared.

Mapping snapshot keys to classes, and their canvas assignment, is gameSets/entitiesOnCanvas in client.js. There's no fixed contract for a part — use the existing ones as a template when creating a new one.

Factory ​

factory — an entity-name → class registry. GameCtrl.parse(name, data) creates an instance from incoming data, calls update(data) on an existing one, or removes it (null).

Providers ​

  • BakingProvider (BakingProvider) — one-time procedural texture generation at startup from the bakedAssets config; baking functions live in the game plugin's src/client/bakers/ (e.g. @vimp-games/tanks's; no fixed interface, follow the existing ones). A baker owns what it returns: re-baking destroys the previous result (each object once per pass, even if it was returned under several keys) together with its TextureSource, so a view onto a shared atlas must not be returned.
  • DependencyProvider — injects services (renderer, soundManager, assetsBase, localPlayer) into components via the componentDependencies map. localPlayer ({ id, is(id) }, localPlayer) answers whether an entity belongs to this client: a part compares the id it got as the fourth constructor argument ({ id }) with the client's own game id, read lazily out of the client core. That is how a game plays a cue for the local player only instead of for every entity on the canvas. assetsBase is the active game's asset base taken from its manifest: a part that draws from image files builds its own URLs as ${assetsBase}img/<file>, the same way sounds resolve to ${assetsBase}sounds/. The engine ships no game images — they travel in the plugin package (dist/img/). Next to the engine's own services the pool carries the game's ones: ClientPlugin.hooks.services(core) (optional) returns a map that is merged into the pool before the engine keys, so a game reaches its own core from a part without the engine knowing what it hands over (the tanks plugin serves mapDynamics that way — the geometry of the predicted map dynamics, which the shot effect uses to anchor its debris to the box it hit). An engine key always wins a name clash, and a service nobody declared in componentDependencies is simply never handed out. An unknown name is not a load failure: the part simply gets undefined (which is why vimp-contract has rule C4 — an unserved service looks like a blank canvas with no error). Names a published game wrote into componentDependencies keep working.

SoundManager ​

SoundManager (built on Howler.js). Sounds are described in the game plugin's src/config/sounds.js (e.g. @vimp-games/tanks's); its path field is overridden client-side (by the client on CONFIG_DATA) to ${activeGameManifest.assetsBase}sounds/ — the game build's own sound copy served alongside its client/host bundles (the game plugin's dist/sounds/), rather than the engine-bundled /sounds/ static copy.

  • UI/system (no position): playSystemSound(name) — plays instantly, bypassing priorities (also used for port 6 sounds).
  • Spatial (positioned in the world): registerSound(name, { position }) → processAudibility() → updateActiveSounds() — the manager decides what's audible on its own, honoring a voice limit (WORLD_VOICE_LIMIT = 30) and priorities from the config.
  • Non-spatial (the player's own): registerSound(name, { position, spatial: false }) — the source belongs to the player, not to the world; it plays dry (no panning) because it sits on the listener. The flag can be changed later with updateSoundData(id, { spatial }).
  • Unregistering: unregisterSound(id) stops the instance and drops the registration (the entity's sound dies with it); releaseSound(id) drops the registration but lets an already playing one-shot finish (a loop is still stopped).
  • reset() stops every playing instance and keeps looped registrations (entities unregister them in their own destroy()); one-shot registrations are dropped.

Virtual elevation and spread ​

The listener always sits at (0, 0, 0) and a world source is placed by the vector from it. The listener is lifted virtualElevation above the plane of the game, and inside innerRadius that vector is faded out with a smoothstep, so the position is one continuous formula per frame — no threshold, no step, no click:

dx  = x - listenerX
dy  = y - listenerY
d2d = Math.hypot(dx, dy)

zoom = camera zoom multiplier (1 = at rest, < 1 = zoomed out)
H    = virtualElevation / zoom
R    = innerRadius      / zoom

spread = smoothstep(0, R, d2d)     // 0 at the centre, 1 past the radius
sx = dx * spread
sy = dy * spread

The projection profile (mode) maps (sx, sy, H) onto the Web Audio axes. World x grows right and world y grows down, while the listener's up vector (Howler.orientation(0, 0, -1, 0, 1, 0)) is +Y — hence the minus sign wherever world y lands on axis Y:

ProfileXYZdefault panningModel
topDownsx-HsyHRTF
sideScrollersx-sy * verticalFactor-Hequalpower
cockpitsx-sy-HHRTF

At d2d = 0 a topDown source sits at exactly (0, -H, 0): straight below the listener, equal in both ears, with no ear filtering. At dx = 20, H = 180 the azimuth is atan(20/180) ≈ 6.3°; at dx = 600 it is ≈ 73°. The real 3D distance is never shorter than H, which is why refDistance sits above it (200 > 180) — a source right next to the player still plays at full volume. equalpower is the default of sideScroller because front/back is physically meaningless in a side view while a clean left/right timbre is not, and verticalFactor (0.2) holds the vertical down for the same reason a platformer's height is barely readable by ear.

The scene scale (canvas scale × dynamic camera zoom) divides the elevation and the spread radius, but not the attenuation distances: refDistance, maxDistance and rolloffFactor stay in world units.

parts.sounds.spatial ​

The block is optional: a game that declares nothing gets the engine defaults (vimp-engine/src/config/clientDefaults.js) and sounds right. Every key is validated on its own and falls back to its default with a console warning, so a bad value cannot break the audio context:

javascript
parts: {
  sounds: {
    codecList: ['webm', 'mp3'],
    spatial: {
      mode: 'topDown',          // 'topDown' | 'sideScroller' | 'cockpit'
      virtualElevation: 180,    // world units, ear height above the plane
      innerRadius: 40,          // world units, the player's own size
      verticalFactor: 0.2,      // sideScroller only: vertical contribution
      panningModel: 'HRTF',     // 'HRTF' | 'equalpower' (profile default)
      distanceModel: 'inverse', // 'linear' | 'inverse' | 'exponential'
      refDistance: 200,         // world units, full volume up to here
      maxDistance: 1200,        // world units, silence past it
      rolloffFactor: 0.9,
    },
    sounds: { /* the catalog */ },
  },
}

The numbers are world units, not screen pixels. The manager is fed world coordinates (the camera from the core's hot buffer, regSound.position from the game's parts), while the screen scale is a separate factor of CanvasManager: currentScale = baseScale * (window width / 1920). In @vimp-games/tanks (mapScale 0.3, baseScale 5) one world unit is 5 screen px; in @vimp-games/snakes (mapScale 1, baseScale 1) it is exactly one.

  • innerRadius is the player's own size in world units: everything sounding inside it is folded smoothly to the centre and spread evenly over both ears. Tanks: a hull of 8 × 6 units, half-diagonal ≈ 5. Snakes: baseRadius 14. The engine default 40 is only right for a game whose world unit equals a screen pixel.
  • virtualElevation is calibrated off the visible half-height of the screen in the design window (1920×1080): H ≈ (canvas height / 2) / baseScale. Bigger H is softer panning near the player, smaller is a more aggressive ear separation. For a game with mapScale 0.3 and baseScale 5 that gives H ≈ 540 / 5 = 108, and half the screen across is 960 / 5 = 192 units — an azimuth of atan(192/108) ≈ 60° at the edge of the screen, while at the size of the player it is a few degrees. Only the design window needs to be calibrated: on any other window size the engine scales H itself through the scene scale above, so the number a game declares stays correct on a laptop and on a 4K display alike.

A typo in a key, or a mode the engine does not know, is a silent fallback to the default. Statically that is caught only by vimp-contract rule E6 — see debugging.md.

InputListener ​

InputListener — low-level keydown/keyup capture for Controls; modes/cmds take priority over the game key set.

UI hierarchy (z-index) ​

vimp (1) → radar (2) → chat (3) → vote (5) → game-informer (6) → panel/stat (7) → lobby/auth/session-overlay (8) → tech-informer (9). The panel sits above the transparent #game-informer, which would otherwise swallow clicks on it, and under the game's auth screen. The lobby screens and the room menu belong to the platform and are not part of the plugin contract.


← Previous: Host meta layer · Next: Maps and assets →

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