Appearance
11 — Authoring workflow
The order in which to build a game plugin, and what each step depends on. Follow it top to bottom; skipping ahead produces work you will redo.
Step 0 — read the contract
Read the SDK documentation pages in full, in the order given in README.md. The questionnaire assumes you know what the engine already provides, so that you can tell the user "the engine does that" instead of designing it again.
Step 1 — interview
Run questionnaire.md. Rules:
- Conduct it in the user's language, whatever the file's language.
- Ask block by block, not all 14 blocks at once.
- Offer a "same as tanks" default for every question and let the user accept it with one word.
- When an answer conflicts with the engine (a custom room setting, a kick vote, runtime localisation), say so immediately and offer the nearest workable alternative.
- Stop asking about anything the user has already settled implicitly.
Step 2 — design document
Write a short design doc and get explicit confirmation before generating code. It must pin down:
| Decision | Feeds |
|---|---|
Package name, id, title | package.json, manifest, both plugins |
| Repository URL | package.json repository/homepage — the lobby links to it next to the game's version; without it there is no link and rule A7 warns |
| Teams and spectator team | gameConfig.teams, stat schema, maps |
| Entity types and their fields | snapshot schema, Rust rows, parts |
| Actions and keys | playerKeys, keySetList, core input handling |
| Resources shown in the HUD | panel.fields (host) + panel schema (client) |
| Weapons/abilities | parts.weapons, core weapon logic, event snapshot keys |
| What is predicted locally | the 8 × f32 player state, predictor, parity test |
| Canvases and camera | canvasManager.canvases |
| Maps and team capacity | data/maps/*, respawns |
| Sounds and cues | sound config, soundCues |
| Progression | playerState.defaultState, onCoreEvent |
Step 3 — scaffold
Do not hand-write the tree — scaffold it:
bash
npm create vimp-game <directory> -- --id <game-id> --title "<Title>" \
--repository <user/repo>Pass --repository (a full URL or the user/repo shorthand) at this step rather than editing package.json later: the platform reads the project link from the published package (homepage, else repository), and adding it after publication costs a republish.
The result is the layout of packaging.md already filled in: the whole build infrastructure (package.json, vite.config.js, vitest.config.js, eslint.config.js, Cargo.toml, the five build scripts), the pins of vimp-engine and vimp-engine-core at their current versions, a dev harness, and a minimal but playable game — two teams, one actor class, one hitscan weapon, one procedural map, bots. It passes check:contract, core:test, test, build and sim from the first commit, so every later step has a green baseline to diff against. The generated CLAUDE.md states the boundaries and the check commands.
From here the work is replacement, not creation: rewrite the configs, the core and the parts of that minimal game into the design document from step 2. assets/ holds two authored inputs: audio-raw/ and img/ — the tile sheets and dynamic-body sprites your maps name. The engine ships none of those.
Writing the tree by hand is still possible (packaging.md specifies every file), but nothing then guards the silent invariants the template already satisfies.
Step 4 — configuration
Write the config layer first; it is the contract everything else implements.
src/config/snapshot.js— the wire schema.src/data/models.js,src/data/weapons.js— the catalogs.src/config/game.js—HostPlugin.gameConfig(imports the above).src/config/client.js— the client half.src/config/auth.js— the auth screen.src/data/maps/*.js— at least one map with fullrespawns; everyspriteSheet.imgandphysicsDynamic[].imgit names must exist inassets/img/.src/config/sounds.js— the sound registry.
Cross-check as you go: panel keys match on both halves; playerKeys names match keySetList names; every snapshot key has a gameSets entry; every gameSets class is in entitiesOnCanvas.
Step 5 — Rust core
core/src/config.rs— deserialise thegamehalf of the init JSON.core/src/<actor>.rs— actor spawn/damage/vitals.core/src/motion.rs— movement math, shared with the predictor.core/src/<game>.rs—impl GameSim, includingbuild_snapshot_blocksproducing rows in the exact schema order.core/src/client/predictor.rs— local prediction usingmotion.rs.core/src/client/mod.rs—impl GameClientDef.core/src/lib.rs— the two structs plusexport_game_core_abi!/export_client_core_abi!and hand-writtennew+ game-specific client methods.
Build: npm run core:build.
Step 6 — client rendering
src/client/parts/*— one class per entity projection.src/client/bakers/*— procedural textures.src/client/<game>.css— styles for your panel cells and teams.src/client/index.js— theClientPluginwith the three hooks.src/host/index.js,createModules.js,systemMessages.js, chat commands, and thescripted(bot) manager.
Step 7 — tests
Two suites:
JavaScript (Vitest) — mirror the tanks arrangement, two projects:
js
projects: [
{
test: {
name: 'game',
environment: 'happy-dom',
include: ['tests/host/**', 'tests/client/**', 'tests/config/**'],
},
},
{ test: { name: 'integration', environment: 'node', include: ['tests/core/**'] } }, // drives core/pkg-node
];Cover at minimum:
- the host plugin exposes every required field and the config passes the engine's required-paths check;
- the client config and host config agree on panel keys, key names, snapshot keys,
gameSets↔entitiesOnCanvas; - chat commands and the bot manager behave;
- the manifest script produces a well-formed manifest.
Rust (cargo test --workspace) — unit tests per module plus a motion parity suite: step the authoritative sim and the predictor with the same inputs and assert identical state. Run it after every movement change.
Step 8 — build and run
bash
npm run core:build # wasm-pack web + nodejs
npm run build # client → host → assets → manifest
npm run dev # standalone: a match against bots in one tabvimp-engine/standalone runs the whole match inside one tab of the game repository — no sign-in, no lobby screen; the scaffolded dev/main.js already wires it. startStandaloneGame({ hostPlugin, clientPlugin, wasmUrl, … }) takes the live plugin objects, so it is the fastest loop while the plugin is still taking shape:
js
// dev/main.js in the game repository, loaded by its own index.html
import { startStandaloneGame } from 'vimp-engine/standalone';
import hostPlugin from '../src/host/index.js';
import clientPlugin from '../src/client/index.js';
import wasmUrl from '../core/pkg/my_game_bg.wasm?url';
await startStandaloneGame({
hostPlugin,
clientPlugin,
wasmUrl,
container: document.getElementById('game'), // full-screen, position: relative
// dev root of your own assets: the client reads `${assetsBase}img/` and
// `${assetsBase}sounds/`, both staged into build/ by the copy scripts
assetsBase: '/build/',
playerName: 'dev',
startupVotes: [['teamChange', 'team1']], // leave the spectators first…
startupCommands: ['/bot 4'], // …only then your own chat commands
});The engine has no notion of a bot: scripted participants are spawned by your chat command, and the command is rejected while the player is still a spectator — hence the strict order of the last two options. Reference: standalone.md.
Step 9 — headless simulation (do this before the browser)
Two browser tabs are the slowest and least informative way to find a broken contract. Run the headless runner first — it closes the whole loop (host → binary frame → client core → scene) in one Node process and names every violated contract in text. Full reference: debugging.md.
bash
npm run sim -- --scenario <scenario.json>- [ ]
entries.wasmNodepoints at the copy of the Node core insidedist/(./core-node/<crate>.js) — otherwise pass--core <path>. - [ ] Your own scenarios, not just the built-in one: without
--scenariothe runner drives a one-key smoke built from yourgameConfigand skips invariants 2 and 9, which cannot be meaningful for a game it does not know. - [ ] One scenario per major mechanic: movement, firing, death/respawn, round end, map change, a vote.
- [ ] Every invariant green — in particular
snapshotKeysUsed,fieldWidths,renderCoverage,panelContract,keyBindings: these are the silent failures frompitfalls.md, mechanised. - [ ]
--determinismgreen once the match is stable. - [ ] Prediction drift within threshold (implement
GameClientDef::predicted_statefor a component-wise report).
Fix everything the runner reports before opening a browser. Anything the browser later catches can be recorded with window.__vimpDebug and replayed here via npm run sim:replay.
Step 10 — manual check
Open npm run dev in a browser tab (the standalone match):
- [ ] The map renders on every canvas; the radar (if any) matches.
- [ ] Movement is responsive and the bots move smoothly.
- [ ] Firing produces exactly one effect locally (no duplicate from the authoritative event).
- [ ] The panel updates: resource bar, ammo, active weapon, round timer.
- [ ] The stat table (Tab) lists the participants with correct teams and updates score/deaths.
- [ ] Chat works; a game system message renders with its text.
- [ ] A vote can be started, voted on and resolved.
- [ ] Sounds play, spatially for the bots' shots.
- [ ] Killing ends the round; scores and the winner message are correct.
- [ ] A map change vote rotates the map and rebuilds everything.
- [ ]
npm run check:contractis green anddist/manifest.jsonis complete: the platform skips a package it cannot read, so the game would be invisible in the lobby.
Rebuild matrix
| Change | Required commands |
|---|---|
src/config/*, src/data/* (JS) | dev: nothing (HMR); prod: npm run build |
src/client/**, src/host/** (JS) | dev: nothing (HMR); prod: npm run build |
Maps (src/data/maps/*) | npm run build:assets && npm run build:manifest (the platform reads dist/maps/) |
| Sounds (raw assets) | npm run audio:process && npm run build:assets && npm run build:manifest |
Images (assets/img/*) | npm run build:assets && npm run build:manifest; in dev predev already staged build/img/ |
| Rust core | npm run core:build (+ reload; prod also npm run build) |
roomForm / roomDefaults | npm run build:manifest |
| Anything, before the first run | full npm run core:build && npm run build |
Two rules behind the table: the platform reads dist/manifest.json and dist/maps/ from the built package, and npm run build never rebuilds the WASM core.
Definition of done
- [ ]
npx eslint .clean. - [ ]
npm testgreen (both Vitest projects). - [ ]
npm run core:testgreen, including motion parity. - [ ]
npm run core:build && npm run buildsucceeds from a cleandist/. - [ ]
npm run sim -- --game <package>exits0on every scenario you wrote (seedebugging.md). - [ ] The manual checklist above passes in the standalone tab.
- [ ] The game is published and submitted (
submitting.md). - [ ] Every box in
pitfalls.mdis verified.