Pitfalls and invariants
Verify a plugin against this list before declaring it done. Almost every item here fails silently or with an error far from its cause.
Items marked ⚙ are checked by machine: run npx vimp-contract in your package and the rule id in the marker (A1 … E7) is the one that will name the violation. Do not verify those by eye — run the tool (debugging.md). The rest of the list is still yours: the headless runner (npm run sim) covers part of it at run time, the rest is review.
Contract rules at a glance
Source of truth: vimp-engine/src/devtools/contract/rules/ (one file per rule). error fails the check, warn fails it only under --strict. Rules B2, B3, B5, C4 and C10 may downgrade a particular violation to warn on a given run (verdict(..., WARN) in contract/result.js), so the level in the report can be lower than the Level column. Rules not named in the checklist below are visible only in this table and in the vimp-contract report.
| Rule | Level | Checks |
|---|---|---|
A1 | error | package.json: type, files, pixi.js, vimp-engine, publishConfig |
A2 | error | the standard build/test scripts exist |
A3 | error | entries live at index and index |
A4 | error | vite.config.js carries the required rollup options |
A5 | error | core/Cargo.toml: crate-type, rapier2d determinism, engine crate pin |
A6 | error | dist/manifest.json: id, entries on disk, roomDefaults coverage |
A7 | warn | package.json declares homepage or repository |
B1 | error | HostPlugin exports every field the engine dereferences |
B2 | error | engineApi consistent; requires names existing capabilities and agrees across manifest and halves |
B3 | error | gameConfig has the paths the engine reads before any logic |
B4 | error | spectatorTeam is a teams key and at least one playing team exists |
B5 | error | roomForm uses honoured field names and existing controls |
B6 | error | host panel.fields does not use the engine key t |
B7 | error | chat commands are well-formed and unique |
B8 | error | system message codes stay out of the engine ranges |
B9 | error | custom votes do not reuse the reserved vote names or the @ prefix |
B10 | error | every playing team has respawns and they cover maxPlayers |
C1 | error | ClientPlugin exports parts, bakers, styles and all three hooks |
C2 | error | every gameSets class is in entitiesOnCanvas and exported in parts |
C3 | error | every snapshot key and map setId has a gameSets entry |
C4 | error | componentDependencies name only existing services |
C5 | error | the client panel maps key t to a type: 'time' field |
C6 | warn | stat columns past the engine layout are styled by the plugin |
C7 | error | keySetList: spectator actions, engine key codes, playerKeys parity |
C8 | error | bakedAssets names exist in ClientPlugin.bakers |
C9 | error | every system message code has a client text |
C10 | error | authSchema: fieldsId, no nickname field, the model field, inline options, resolvable validators |
C11 | error | gameConfig.statMode and modules.stat.params.mode agree |
D1 | error | snapshot block ids are unique |
D2 | error | class hot only on indexed8/indexedNoNull8; list16/indexed32 are class: 'event' |
D3 | error | interp only on f32 fields of hot blocks; role: 'state' is a u8 right after the row head |
E1 | error | every declared sound ships as a webm + mp3 pair |
E2 | error | map images exist in dist/img/ |
E3 | warn | the sound registry is not empty |
E4 | error | layered (2.5D) maps are structurally sound |
E5 | error | solid tiles are named by a render layer |
E6 | warn | the spatial sound block is well-formed |
E7 | error | a map's game field is a plain object |
Version and identity
- [ ] ⚙
B2engineApiappears in three places (manifest,HostPlugin,ClientPlugin) and all three importENGINE_API_VERSIONrather than hardcoding a number. - [ ] ⚙
A6manifest.idequals theidof both the host and the client plugin. - [ ]
manifest.idalso equals the id the game is registered under on the platform (manual check, see submitting.md). A mismatch makes the platform skip the game — it simply never appears in the lobby. - [ ] A game the platform cannot read (missing manifest, id mismatch) is skipped, not reported to the user. When a game is missing from the lobby, run
check:contractand checkdist/manifest.jsonfirst. Age is not a reason any more: a game built against an older engine is served as is, and a game whoserequiresnames an unknown capability is shown in the lobby as unavailable with the reason.
Host plugin
- [ ] ⚙
B1chatCommandsis present and is an array — the engine iterates it unguarded. Use[]for none. - [ ] ⚙
B1createModulesis present and its result has ascriptedkey. - [ ] ⚙
B1buildClientGameConfigis present — it is called unconditionally. - [ ] ⚙
B3All fourREQUIRED_GAME_CONFIG_PATHSexist:parts.models,playerKeys,snapshot,teams. Everything else the engine reads has a default (createGameConfigView) —B3only warns about it. If you do declarespectatorTeam, spell it exactly as one of theteamskeys (the gate checks that too); omitted, it resolves to thespectatorskey, ornullwith aconsole.warnif there is none. - [ ] The
createModulescontext has notimerManagerand novoteCoordinator(a comment in the tanks source says otherwise — it is wrong). Only chat-command handlers get those. - [ ]
gameConfigis merged shallowly over engine defaults: overridingtimers,rttoridleKickTimeoutreplaces the whole object. Restate every key you still need. - [ ] The core's
timeStepcomes from the engine'shostDefaults, not from your mergedgameConfig.timers.timeStep. Overriding it desyncs the Worker loop from the physics step. - [ ] ⚙
B5Room form fields outside the whitelist (maps,maxPlayers,map,roundTime,mapTime,friendlyFire) are accepted by the form and dropped by the host without a word. Do not design rules around a custom room setting. - [ ] Engine meta modules (
Panel,Stat,Chat,Vote,TimerManager) are singletons. Constructing a second one returns the first — a "fresh" instance silently shares state.
System messages and votes
- [ ] ⚙
B8The groupss,v,m,calready hold engine messages (indexess:0..11,v:0..15,m:0..1,c:0..1). Registration is a blindObject.assign: a code of yours at a reserved index overwrites an engine message with no warning. Start your own codes above them. - [ ] No
/nameor other nick-change command — the engine cannot rename a player. - [ ] ⚙
C9Every code you register has a matching text at the same index in the client'smodules.chat.params.messages[group]. A missing text renders as nothing. - [ ] ⚙
B9Vote namesmapChangeandteamChange, and the templatevaluesstrings'teams'/'maps', are reserved. - [ ] ⚙
B9Vote names starting with@(templates and menu entries) and the chat command/changehostare reserved by the engine (the platform's "Change host" vote, lobby mode). Engine codes have English defaults on the client;''at the same index silences one. - [ ] A vote category is on cooldown for
timers.timeBlockedVote(30 s); always callcanCreateVotefirst. - [ ] Vote ties are broken randomly — do not rely on a deterministic outcome.
Snapshot protocol
- [ ] ⚙
D1Every snapshotidis unique. Uniqueness is the only thing validated; nothing checks that fields match the Rust rows. - [ ] Field order and
interpare positionally bound to the Rust row construction. Reordering on one side only produces garbage, not an error. - [ ] ⚙
D2The hot buffer carries onlyindexed8andindexedNoNull8blocks. Anything rendered smoothly at frame rate must use one of those kinds;indexed32/list16arrive throughtake_frames(). - [ ] ⚙
D3Onlyf32fields interpolate, and only inclass: 'hot'blocks. - [ ] ⚙
D3role: 'state'is au8right after the dynamics row head (index 5 layered, 3 flat) and beforeoptionalFrom. - [ ] Your core calls
vimp_engine_core::physics::round2on thef32s it packs. The packer does not round; the decoder does, so an unrounded value silently differs between host and client. The player block is exempt (packed and decoded raw). - [ ] Weapon event blocks put the author id last — client-side duplicate suppression depends on it.
- [ ] ⚙
D2Events must live inclass: 'event'keys, otherwise the frame is not classified as reliable and can be dropped by the unreliable channel.
WASM core
- [ ]
PLAYER_STATE_LEN = 8— the predicted local state is exactly eightf32s. Anything else must be derived client-side or left unpredicted. - [ ] Host init JSON uses
timeStepin seconds; client init JSON usestimeStepMsin milliseconds. - [ ] The ABI macro requires the struct fields to be named exactly
stateandpacker(host) /state(client), with the exact engine types. - [ ]
new()is hand-written; the macro never generates it. - [ ] Game-specific client methods (
set_model,sync_panel,try_fire,cycle_weapon) are hand-written and are only ever called fromClientPlugin.hooks. - [ ] Body tag low byte
1is the engine's map-object tag; game kinds start at2. Map dynamic bodies carry their index in the higher bits — useis_map_object, not== MAP_OBJECT_TAG. - [ ] Never remove a map body from the world:
EngineSimremoves them on the next map load and a double removal panics. Disable it (set_enabled(false)) and flip itsmap_body_statebyte. - [ ] At most 255 map dynamic bodies — the row index is a
u8. Debris and wrecks are drawn, not spawned as new bodies. - [ ]
on_map_loadedis not called ondeserialize_state; anything derived from the map must survive in your own dump. - [ ] All randomness goes through the engine
Rng(SplitMix64) seeded from the config.rand,Math.randomor clock-seeded values break determinism and therefore prediction. - [ ] ⚙
A5rapier2dhas theenhanced-determinismfeature enabled. - [ ] Movement math is shared between the authoritative sim and the predictor, and a
cargoparity test asserts they agree. Re-run it after any movement change. - [ ] The core is not rebuilt by
npm run build— runnpm run core:buildafter Rust changes or you will ship a stale.wasm.
Client plugin
- [ ] ⚙
C2A part class is registered only if it appears inentitiesOnCanvas. Listing it inpartsandgameSetsis not enough — you getConstructor for X not found. - [ ] ⚙
C3Every snapshot key (and every mapsetId) has agameSetsentry, or the first frame throws. - [ ]
createClientCorereturns{ core, memory }. Returning the core alone breaks the zero-copy hot-buffer read. - [ ] ⚙
C1All three hooks (onAuth,onPanel,onLocalAction) exist; no-op bodies are fine, missing ones crash. - [ ] Draw order: set
zIndexon part instances — nothing else affects it. The engine marks the stagesortableChildrenand callssortChildren()after everyaddChild; PixiJS v8 sorts byzIndexthere. Alayerproperty on the instance does nothing. - [ ] ⚙
C4The engine provides exactly six dependency services:renderer,soundManager,assetsBase,localPlayer,accolades,diagnostics. Your game adds its own by returning them fromClientPlugin.hooks.services(core)— the engine merges that map into the pool. A name that neither side provides resolves toundefinedin the part, silently.C4reports it as an error when the plugin declares nohooks.services(), and as a warning when it does (the checker cannot call the hook without a live core — run with--strictto fail on those too). - [ ] Never list
diagnosticsinrequires: the service is optional, an older engine hands the partundefined, and arequiresentry would reject the game there for nothing. Call it asdiagnostics?.warn(…). - [ ] Do not reuse an engine service name in
hooks.services(): the engine merges your map first ({ ...gameServices, renderer, … }), so arendererof yours is silently overwritten by the engine's and no rule can see it. - [ ] ⚙
C8Baker names inbakedAssetsmust exist inbakers; unknown names are skipped silently. - [ ] ⚙
B6, C5The panel keytis hardcoded by the engine (round time in seconds). Declare atype: 'time'field for it on the client and do not usetin your host panel schema. - [ ]
panel.fields[*].valueon the host is both the HUD starting value and the core's starting resource amount — they cannot diverge. - [ ] The engine writes five stat names of its own:
name,status,score,deaths,latency, and only into columns your schema declares — a write to an undeclared one is dropped. Any further column is yours to declare and to populate from your host code; the engine never fills it for you. - [ ] Stat sorting is numeric (
~~textContent); a text column sorts as0. - [ ] ⚙
C6The engine's CSS lays out five stat columns (#stat …:nth-child(1)…(5)) and the.line1–.line3row classes. A sixth column is fine, but it gets no width of its own until you restate the layout inClientPlugin.styles—C6warns for every declared column your styles leave unaddressed. It looks for a width declaration (width/min-width/flex/…) on a#statselector naming a cell, so a rule that only sets a colour does not count as laying the column out. - [ ] ⚙
C7keySetList[0]is the spectator set and must containnextPlayerandprevPlayer;keySetList[1]is the player set. - [ ] ⚙
C7Key codes67(chat),77(vote),9(stat),27(escape),13(enter) belong to the engine. - [ ] ⚙
C7keySetList(code → name) andplayerKeys(name → bit) must list the same action names. - [ ]
type: 1(one-shot) semantics are implemented by your core, not the engine — the engine only forwardsplayerKeysand the rawdown/upevents. Implement the mask pattern (downsets a pending bit, one fixed step consumes it,upignored) in the sim and the predictor, or atype: 1fire key autofires. - [ ] ⚙
C10The auth schema element key isfieldsId, notformId— it names the container the engine fills with the form's fields. A wrong key resolves tonulland the auth screen dies with aTypeErroron the first render. - [ ] ⚙
C10There is no nickname field — identity comes from the lobby JWT. - [ ] ⚙
C10No param usesoptions.source— the auth form is built without the engine's catalogs, so such a field resolves to an empty list, showsno options availableand nobody can log in. Inline the options. (sourceis fine inroomForm, where the engine does pass the maps.) - [ ]
authSchema.validatorsare functions, are not serialised, and run on the host. - [ ] ⚙
C10A param'svalidatornames a functionauthSchema.validatorsactually provides — a typo (or a non-function value) leaves the field checked by nobody: the host skips an unresolved validator silently and writes aconsole.errorwhen the port machine is built. - [ ] The host also applies the auth descriptor's own declarative rules (
validateAuth), so a client that bypasses the form is bound by them too: length, membership in aselect/radiofield's declaredoptions(not an option; an empty or absent list accepts nothing, as in the form), thenmaxlength/regExpon text fields.requiredandmin/maxare the exceptions — an empty value is left to your validator, and a numeric auth field cannot work at all (the value must be a string). - [ ] A field with no
maxlengthis capped at 256 characters on the host: yourregExpruns there against whatever the client sent, and a catastrophic pattern would freeze the match, not just a tab. - [ ] ⚙
B5Declare one of the four native controls —text,select,checkbox,radio. The four retired in v3 (range,number,toggle,segmented) still work forever, as permanent aliases oftext+numeric,text+numeric,checkboxandradio, so an older game keeps rendering — but B5 (and C10 forauthSchema) warns on them, so write the native name. The alias applies to the host's authoritative validation too, not just to rendering: asegmentedfield is checked against its options list exactly likeradio, and anumber/rangefield is checked againstmin/maxin the declared display unit. Acontrolthe engine's registry does not know at all skips the field with aconsole.error. Nativemin/max/stepattributes are never emitted (these fields aretype=text): the descriptor's ownmin/maxnumbers drive the label hint and the check instead, alongsideregExp. - [ ] ⚙
B5AregExpthat does not compile is no constraint at all — the engine drops the check with aconsole.errorand the field passes. The engine anchors it as^(?:…)$, the way a browser appliespattern, so write it unanchored. - [ ] Keep that
regExplinear. The form re-checks itself on every keystroke, so a pattern that backtracks catastrophically freezes the tab while the player types, not just on submit.
Assets and maps
- [ ] ⚙
E2Tile sheets and dynamic-object images ship in your package (dist/img/) and load from${assetsBase}img/, exactly like sounds load from${assetsBase}sounds/. The engine serves no game images. - [ ] The part that loads images must declare
assetsBaseincomponentDependencies— an undeclared service is silentlyundefined, and the map renders as a blank canvas with nothing in the console (C4only checks that a declared service name exists). - [ ] ⚙
E2A map naming an image that is not indist/img/fails silently at runtime. Catch it in the manifest build instead. - [ ] ⚙
E1Every sound exists as awebm+mp3pair; a missing.mp3breaks Safari. - [ ] Do not set
sounds.path— the engine overwrites it. - [ ] ⚙
B10respawns[team].lengthis the hard capacity of that team on that map. Too few points silently caps the room belowmaxPlayers. - [ ] Maps are scaled by the host (
step, dynamic positions/sizes, respawns); do not scale again in a part. - [ ] ⚙
E7The map'sgamefield is not scaled by anyone: put grid cells (or unscaled units you multiply byscaleyourself) in it. It is an object or absent — an array fails the map load. - [ ] At most 30 simultaneous world voices; ranking is
priority² / max(distance², 1). - [ ] ⚙
E6A typo in aparts.sounds.spatialkey, or amodethe engine does not know, gives neither an error nor an audible hint: the engine falls back to the default in silence. Statically only ruleE6(npx vimp-contract) catches it. - [ ] A
levels.<n>.mapgrid whose dimensions differ frommapis silent at runtime: the actor drives into nothing and nothing reaches the console.MapConfig::validateis the only place that catches it — do not skip it by buildingGameMapdirectly. - [ ] A railing tile listed in
levels.<n>.wallsbut not in.floorhangs in the air: it blocks movement on that level yet does not shield the slab edge from a shot fired below. Every railing has to be part offloor.
Build and packaging
- [ ] Client and host are built by two separate Vite runs (
--mode client,--mode host), never one multi-entry graph — a shared chunk drags DOM code into the Worker bundle. - [ ] ⚙
A4emptyOutDir: false,assetsInlineLimit: 0,preserveEntrySignatures: 'strict',inlineDynamicImports: trueare all required. WithoutpreserveEntrySignaturesthe plugin's default export is tree-shaken away. - [ ] ⚙
A4Do not use Vite'sbuild.lib— it always inlines assets. - [ ] ⚙
A1, A4pixi.jsis external and a peer dependency. Bundling it creates a second PixiJS instance with its own extension registry; cross-instance objects fail at runtime. - [ ] ⚙
A3Entry paths must be exactlyindexandindex— dev mode hardcodes them. - [ ]
core/pkg-web/must exist for dev mode (npm run devimports the.wasmfrom there). - [ ] The plugin must be built at least once (
npm run core:build) before the firstnpm run dev. - [ ] ⚙
A1files: ["dist"]— onlydist/is published.
Host code hygiene
- [ ] Host-side code is Worker-safe: no
window, nodocument, no DOM, no PixiJS, no Node globals. - [ ] Host module state does not survive a handoff — the physics world is not serialised and your JS module state is not carried over (unless the game opts into
migration.midRound, below). - [ ]
gameConfig.migration.midRound: true— then all state must be inGameSim::serialize(core) or inserializeState/restoreState(each stateful host module); anything else resets at a host change. Check withvimp-sim --checkpoint-every <ms>(checkpointRestore): the report's## Host checkpointsmust saymidRound.
Things that do not exist
- No kick vote, no
/banendpoint, no/like·/unlikeserver rating (removed). - No
views: { Panel, Stat }field onClientPlugin. - No
GameClientDef::motion_step,render_from_state, orSTATE_LEN. - No
spawn_scripted/build_blocks(the real names arespawn_scripted_actor/build_snapshot_blocks). - No generic
SimCtx<'a, G>and nogame_cfgfield on it. - No
gameConfig.models/gameConfig.weaponsat the top level — they live underparts. - No invulnerability window, no pickups, no destructible-terrain system, and no per-player progression rules: model them inside your core and snapshot schema if you need them.
- No runtime localisation: one language per config.
- No in-game debug overlay or debug HUD. What does exist is out-of-band: the engine's headless runner (
npm run sim), which replays a scenario without a browser and reports every broken contract on this page by name, plusdebug_json()world dumps and a prediction-drift detector — all of it free to your plugin. See debugging.md; verifying this checklist with the runner beats verifying it by eye.
← Previous: Debugging (headless runner and the browser half) · Next: Submitting a game →