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, thentake_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) andCLEAR(→reset) clear the frame buffer and the predictor in the core;resetalso 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) andramps— 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 fromlayersover themapgrid, every above-ground level fromlevels[n].layersover thelevels[n].mapgrid. The keyss0..sNrun across all levels (they are part-instance ids inGameModel— twos0s would overwrite each other and half the map would never appear), and each instance receiveslevel(its level number),solid(the tiles that block movement on it —physicsStaticfor level 0,levels[n].wallsabove) andfloor(levels[n].floor, empty on level 0) next tomap,step,layer,tiles,physicsStatic,scaleandspriteSheetit already read. A map withoutlevelsproduces 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_DATAand 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 fromPS_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) andradar(the mini-map); the canvas elements are generated by the client from the game's canvases config (modules.canvasManager.canvases, including the initialwidth/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'sonRendernever sees a half-applied frame. Positions are measured inrenderer.screenunits (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 anyresolution. - Controls — keyboard capture (
InputListener), the active key set dictated by the server (port 17),chat/vote/statmodes, input sent as"seq:action:name". Optionally a pointer channel as well (mouse, finger, stylus — one set of Pointer Events): declared by the game asmodules.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 outsidepointer.keySetsmutes it and releases a held pointer. A game that does not declarepointergets no listener and no traffic — see InputListener. - Game — the rendering core:
GameCtrl.parse(name, data)creates/ updates/removes entity instances from snapshot data throughFactory. - Chat — message output (row/lifetime limits), the command line; escaping happens on output (
textContent); system messages use the engine's English defaults fromconfig/chatMessages.jsunless the game overrides them. - Panel — the HUD: round time, health, ammo, active weapon (from
'key:value'strings).PanelViewgenerates the DOM from the game's schema (modules.panel.fields: an ordered list of{ name, elem, type }; cell semantics come fromtype: 'bar' | 'value' | 'time' | 'weapon', not from field names — abarfield also takesmaxandblocks) inside the engine's#panelcontainer; the cells' look is the game's CSS (bar blocks use the engine-neutralpanel-bar-*classes). The#logoheader inside#panelshows the game's title fromauthSchema.texts.title(same value as#auth-title, applied whenPS_AUTH_DATAarrives, falling back to'VIMP'before that / if absent);#panel/#logoCSS is flex-based so the panel table reflows around titles of any length. - Stat — sortable scoreboard tables (
sortList), shown on Tab.StatViewgenerates the header and tables from the game's schema (modules.stat.params:columns— column labels,bodies— an arbitrary number of teams) inside the#statcontainer; 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 byseqwith deduplication and, if the frame carries a player block, reconciles the predictor.MAP_DATA/PANEL_DATA/KEYSET_DATA/CLEARmirror intoset_map/sync_panel/set_active/reset; the model is set withset_modelon auth.resetmeans "the world is gone": it clears the buffer, the predictor andmy_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 throughhot_ptr()and aFloat32Arrayover 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 throughClientPlugin.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 atserverNow − 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 fixedtimeStep; 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'sserverTime, 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 thebakedAssetsconfig; baking functions live in the game plugin'ssrc/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 itsTextureSource, so a view onto a shared atlas must not be returned.DependencyProvider— injects services (renderer,soundManager,assetsBase,localPlayer) into components via thecomponentDependenciesmap.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.assetsBaseis 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 servesmapDynamicsthat 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 incomponentDependenciesis simply never handed out. An unknown name is not a load failure: the part simply getsundefined(which is whyvimp-contracthas rule C4 — an unserved service looks like a blank canvas with no error). Names a published game wrote intocomponentDependencieskeep 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 withupdateSoundData(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 owndestroy()); 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 * spreadThe 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:
| Profile | X | Y | Z | default panningModel |
|---|---|---|---|---|
topDown | sx | -H | sy | HRTF |
sideScroller | sx | -sy * verticalFactor | -H | equalpower |
cockpit | sx | -sy | -H | HRTF |
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:
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.
innerRadiusis 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 of8 × 6units, half-diagonal≈ 5. Snakes:baseRadius 14. The engine default40is only right for a game whose world unit equals a screen pixel.virtualElevationis calibrated off the visible half-height of the screen in the design window (1920×1080):H ≈ (canvas height / 2) / baseScale. BiggerHis softer panning near the player, smaller is a more aggressive ear separation. For a game withmapScale 0.3andbaseScale 5that givesH ≈ 540 / 5 = 108, and half the screen across is960 / 5 = 192units — an azimuth ofatan(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 scalesHitself 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.