# Gameable Engine — building a first-person shooter # Everything needed to write an FPS with Gameable Engine, and nothing else. # Generated by tools/docs/gen-llms.mjs. Do not edit. # How to use these files A generated bundle of the Gameable Engine docs. Each file starts with `# FILE: `. 1. Read AGENTS.md, the first file below. Its numbered rules are enforced by ESLint and `npm run check`, not advice. 2. Copy templates/brawl or templates/collect or templates/fps or templates/hangout or templates/mystery or templates/steal or templates/survive or templates/third-person or templates/visit, or run `npm create gameable my-game -- --template brawl`. 3. Edit `src/game.ts` and `src/prefabs.ts` — plus `src/systems/*.ts` and `src/assets.json` when a recipe says so. **Never edit `packages/*`**: if a game needs an engine change, say so and stop. That is a missing feature. 4. Run `npm run dev`, open http://localhost:5173. A save is a reload; there is no build step. 5. For one task, ask for its recipe by name ("show me docs/recipes/add-a-weapon.md"). Each is Goal / Files you will edit / Steps / Verify / See also, and touches at most two files. docs/recipes/index.md lists them all. Assets are addressed by string id, never a path or URL. Nothing in a system allocates. Import `three/webgpu`, never bare `three`. # FILE: AGENTS.md # AGENTS.md ## What this repository is **Gameable Engine** (npm package `gameable`, scaffolder `create-gameable`, CLI `gameable`) is a browser game engine. Worlds are **gaussian splats** rendered by three.js r186's native `GaussianSplat` on a `WebGPURenderer`. **All game logic** is TypeScript compiled to a QuickJS **WebAssembly component**; bitecs 0.4 is the ECS and it lives inside that guest. The host owns rendering, Jolt physics, input, audio, assets and characters. ### Two names for every package One npm workspace: the engine is split into private workspace packages under `packages/*`, named `@gameable/` (`@gameable/core`, `@gameable/sdk`), and they are published together as one package, `gameable` (`packages/gameable`), whose subpaths re-export them (`gameable/core`, and `gameable` itself for the SDK). `packages/gameable/entries.mjs` is the table. - **Code inside `packages/*/src`** imports its siblings by workspace name: `import { createEngine } from '@gameable/core'`. - **Everything a user sees or copies** — templates, examples, fixtures, docs, READMEs, doc comments, `llms*.txt` — uses the public subpath: `import { createEngine } from 'gameable/core'`, and game code imports only from `gameable`. - A new export in a workspace package needs a row in `entries.mjs`; `npm run gen -w packages/gameable` writes the rest, and the test suite fails until it is there. ## Read this before writing a game If you are building a game, do **not** read the whole repository. Read one bundle: - `llms-fps.txt` — first-person shooter. - `llms-third-person.txt` — third-person adventure. - `llms-visit.txt` — a character's page. - `llms-multiplayer.txt` — rooms and Play Solo. - `llms-character.txt` — splat characters in a game. They are generated, committed and size-budgeted. `llms.txt` is the index and `llms-full.txt` is the core corpus (360 KB cap) if you genuinely need it: everything except the topic bundles' pages (rooms, splat characters), which it points to. Then: copy a template, edit `src/game.ts`, run `npm run dev`, follow one recipe. That is the whole path. ## Commands | Command | Does | | ----------------------- | ----------------------------------------------------------------------------------- | | `npm install` | The only build step. Packages resolve to `src/` via the `gameable-source` condition | | `npm run dev` | Points you at an example; there is no root dev server | | `npm run build` | `tsdown` build of every package | | `npm run typecheck` | `tsc -b --noEmit`, then `tools/typecheck-apps.mjs` for templates and examples | | `npm run lint` | ESLint flat config, type-aware | | `npm run format` | Prettier write; `npm run format:check` to verify | | `npm test` | vitest across `packages/**`, `templates/**` and `tests/**` | | `npm run test:boundary` | The real component in node: jco componentize + transpile, smoke and parity | | `npm run test:e2e` | Playwright: splat-viewer goldens and the FPS template, direct mode | | `npm run test:e2e:wasm` | The same e2e suite against the compiled wasm guest | | `npm run test:llm-eval` | Score a small model on ~20 prompts against the docs bundles | | `npm run docs:api` | TypeDoc markdown into `docs/api/` (generated, gitignored) | | `npm run docs:dev` | VitePress dev server, port 5173. Needs `docs:api` first | | `npm run docs:build` | VitePress production build. Needs `docs:api` first | | `npm run docs:games` | Build the playable examples into `docs/public/play/` for the docs site | | `npm run docs:llms` | Regenerate `llms.txt`, `llms-full.txt` and the task bundles | | `npm run docs:lint` | Dead links, templates, recipe file paths, snippets, llms freshness, size budgets | | `npm run check` | lint + typecheck + test + docs:lint. **Run this before every commit.** | Scripts must work in Git Bash and in `cmd`. Never put PowerShell-only syntax in a package script. Build helpers are node `.mjs` files taking array arguments with forward-slashed paths, because Windows paths break shell-string pipelines. ## Hard rules 1. **Import `three/webgpu`, `three/tsl` or `three/addons/...` — never bare `three`.** The bare entry point pulls in the WebGL renderer and can create a second three singleton. `gameable/no-bare-three-import` fails the build. 2. **No allocation in `fixedUpdate`, `update`, or any guest system.** These run 60+ times a second. Preallocate typed arrays, memoise subarrays, pool command objects, use dirty flags. 3. **Assets are addressed by string id only.** Never a path, never a URL, in game code or across the wasm boundary. Add a manifest entry instead. 4. **When the task is "make a game", never edit `packages/*`.** Edit the game's own `src/`. If a game genuinely cannot be written without an engine change, say so and stop — that is a missing feature, not a workaround. 5. **Never edit generated files.** `llms*.txt`, `docs/api/`, `docs/wit/`, `packages/*/src/generated/` and `package-lock.json` are outputs. Change the generator and rerun it. 6. **Run `npm run check` before committing.** A red `check` is not a commit. 7. **Nothing calls `navigator.gpu.requestAdapter()`.** The renderer owns the `GPUDevice`; everything else borrows `renderer.backend.device`. Bootstrap order is `initWebGPUPatches()` -> `renderer.init()` -> ORT device -> lift pipelines. 8. **Private three internals live in exactly one file.** All `renderer.backend.*` access goes through `packages/splat/src/backendBuffers.ts`. Do not reach into the backend anywhere else. 9. **Seed randomness inside `init()` from `env.seed()`.** Wizer snapshots the QuickJS heap at build time, so module-level state is frozen into the binary and every run would be identical. 10. **The guest gets one export per frame.** `tick(frame-input) -> frame-output`. Continuous data is packed `list`; structural changes are commands. Physics queries are the only synchronous host imports. Do not add a per-entity call. 11. **Keep direct mode and wasm mode identical.** They share the guest runtime on purpose, and a parity test hashes both. If they diverge, that is a bug in the engine, not a configuration difference. 12. **Every exported symbol gets TSDoc with a runnable `@example`.** ESLint enforces it on `packages/*/src/index.ts`, and the docs bundles are built from it. 13. **Dependencies are pinned exactly** (`save-exact=true`), and `three` is additionally pinned in root `overrides`. Do not widen a range. The one exception is what the published packages ask of an app: their `three` peer dependency is `>=0.186.0 <0.187.0`, so an app on a later 0.186 patch installs (an exact peer pin failed `npm install` the day three shipped 0.186.1). The repository itself still develops and tests against the exact pin. 14. **Placeholder assets are CC0 only** and capped at 12 MB. Anything with attribution requirements does not go in `gameable/placeholder`. ## Repository map | Path | What | | --------------------------------------------------------- | --------------------------------------------------------- | | `packages/core` | Engine, loop, module registry, cameras, asset registry | | `packages/sdk` | The only import game code needs; owns the key table | | `packages/wasm-host` | Component loading, host bindings, direct mode | | `packages/splat` | Splat loading plus the `AnimatedGaussianSplat` fork | | `packages/character`, `animation` | Splat avatars (ORL and GNM rigs) and the layered animator | | `packages/assets-aosrig` | The `aosrig_v0` sample character GLB (MHR body, GNM head) | | `packages/physics-jolt`, `input`, `audio`, `assets*` | Host modules | | `packages/cli`, `create-gameable`, `vite-plugin-gameable` | Tooling | | `packages/test-harness` | Mock host, record/replay, determinism helpers | | `templates/fps`, `templates/third-person` | The two shipped scaffolds | | `templates/visit` | A character's page: the studio exporter's inputs in | | `examples/`, `fixtures/` | Viewers, a Rust guest, and smoke content | | `tests/boundary`, `tests/e2e` | The real component in node; Playwright in a browser | | `wit/` | The `gameable:engine` WIT world | | `docs/` | VitePress source: how to use the engine, nothing else | | `adr/` | Decision records: why the engine is the way it is | | `STYLE.md` | The Gameable look: tokens, type, components, rules | | `tools/docs/` | `gen-llms.mjs`, `lint.mjs`, `bundles.json` | | `tools/eslint/` | Local hard-rule plugin | | `tools/llm-eval/` | The small-model scoreboard (M6) | ## Conventions - TypeScript everywhere, ESM only, `type: "module"`. - Package READMEs use six fixed headings: What, When to use, Install, Minimal example, API, Gotchas. `docs:lint` enforces this. - Recipes use five fixed headings: Goal, Files you will edit, Steps, Verify, See also — and edit at most two files. `tools/docs/recipe-template.md` is the shape. - ADRs are immutable once accepted; supersede, do not rewrite. - Commit messages: `type: summary`, imperative, lowercase type. - **Anything with a look follows `STYLE.md`** — the Gameable tokens (dark canvas, mint accent, pink for errors, Plus Jakarta Sans, pills and 16px cards) copied from the `aos-gameable-cc` frontend. Docs theme, template shells, the HUD, overlays and error banners all use them. Do not invent a colour, radius or font. ## When you are stuck `docs/troubleshooting.md` is symptom-first. `docs/glossary.md` decodes the jargon. `adr/` explains why something is the way it is — read the ADR before proposing to change a decision. # FILE: docs/start/02-first-fps.md # Build your first FPS Twenty minutes from nothing to a shooter you can play, and one file to change. ## 1. Scaffold ```sh npm create gameable my-fps -- --template fps cd my-fps npm run dev ``` Open the page, click the canvas, and play: WASD to move, space to jump, mouse to aim, left button to fire, R to reload. Six red-tinted characters chase you across a gaussian-splat arena, three green boxes heal you, and the HUD tells you when the round is over. That is the starting point, not the destination. The rest of this page explains what you were given so you can change it. Working inside the engine repository instead? The same template is a workspace member: ```sh npm install npm run dev -w templates/fps ``` ## 2. What the template contains ``` my-fps/ ├─ index.html ├─ vite.config.ts one plugin: gameable() ├─ src/ │ ├─ main.ts the host: engine boot, lights, arena collider, sandbox │ ├─ game.ts THE FILE YOU EDIT │ ├─ prefabs.ts what things are made of │ ├─ arena.ts where things spawn │ ├─ hud.ts what the HUD shows │ ├─ assets.json asset ids to files │ └─ systems/ │ ├─ weapon.ts fire, damage, reload │ ├─ enemyAI.ts idle -> chase -> attack │ └─ pickups.ts medkits ├─ tests/game.test.ts the whole game, driven headlessly └─ AGENTS.md these rules, scoped to your game ``` Everything under `src/` except `main.ts` is compiled into the **wasm guest**. It may not touch the DOM, fetch anything, or ask for the time. `main.ts` is the host and stays in the browser. ## 3. The shape of `game.ts` ```ts import { defineGame } from 'gameable'; import { arenaSpawns } from './arena'; import { updateHud } from './hud'; import { EnemyPrefab, MedkitPrefab, PlayerPrefab, tint } from './prefabs'; import { enemyAI } from './systems/enemyAI'; import { pickups } from './systems/pickups'; import { weapon } from './systems/weapon'; export default defineGame({ assets: ['env.arena', 'sfx.shot', 'sfx.hit', 'sfx.pickup', 'sfx.step'], world: { gravity: -9.81, maxEntities: 512 }, player: { prefab: PlayerPrefab, spawn: [0, 1.15, 9], camera: 'firstPerson' }, rules: { walkSpeed: 5, magazine: 12, damage: 20, enemySpeed: 2.2, enemySight: 14, medkitHeal: 35, winMessage: 'ARENA CLEARED', }, init: (ctx) => { for (const spawn of arenaSpawns.enemies) { tint( ctx.spawn(EnemyPrefab, { x: spawn.position[0], y: 1.05, z: spawn.position[2] }), 0.82, 0.18, 0.16, ); } }, systems: [movePlayer, weapon, enemyAI, pickups, updateHud], }); ``` `assets`, `world` and `player` are sugar over built-in systems, so two games that declare the same thing behave identically. `rules` is handed straight back as `ctx.rules`, and every tuning number in the template reads from it — change one there and nothing else has to know. ## 4. A system, in outline ```ts import { Health, MOUSE_BUTTONS, Transform, type GameContext } from 'gameable'; /** Reused ray. A system must not allocate. */ const eye = { x: 0, y: 0, z: 0 }; const forward = { x: 0, y: 0, z: -1 }; const HIT_LAYERS = Object.freeze({ enemy: true, staticGeometry: true }); export function weapon(ctx: GameContext): void { if (!ctx.input.mouseDown(MOUSE_BUTTONS.LEFT)) return; ctx.audio.play('sfx.shot', { entity: ctx.player, volume: 0.7 }); aimFromCamera(ctx, eye, forward); // writes into the hoisted vectors const hit = ctx.physics.raycast(eye, forward, 60, HIT_LAYERS, ctx.player); if (hit) Health.current[hit.entity] -= Number(ctx.rules.damage); } ``` `src/systems/weapon.ts` is this plus a cooldown, a magazine and a reload. Four things to notice: 1. **`physics.raycast` is synchronous.** Physics queries are the only blocking host calls a guest may make; everything else is a command applied after the guest returns. 2. **Nothing allocates.** Systems run sixty times a second inside a QuickJS heap. Hoist your vectors and your query term arrays. 3. **`'sfx.shot'` is a manifest id**, not a path. Adding a sound is an entry in `src/assets.json`. 4. **The player is excluded with a layer mask**, not by asking the host to skip it. Cheaper, and it is what layers are for. ## 5. Placeholders, and replacing them Nothing in the template has a model. The host draws a mesh the size and shape of each entity's **physics body** — a capsule for a person, a box for a medkit — so the game is playable before any art exists, and `tint()` colours them. Give a prefab an `asset` and the placeholder is replaced the moment the asset loads: ```ts export const EnemyPrefab = prefab({ asset: 'enemy.grunt', // add it to src/assets.json body: { shape: 'capsule', dims: [0.35, 0.7], kind: 'character', mass: 70 }, health: 40, components: [Enemy], }); ``` ## 6. Change something Pick one; each is a recipe of its own: - [Add a weapon](../recipes/add-a-weapon.md) — a shotgun, in two files. - [Add an enemy](../recipes/add-an-enemy.md) — a second kind, tougher and slower. - [Change the level](../recipes/change-the-level.md) — your own arena and spawn table. - [Add a HUD element](../recipes/add-a-hud-element.md) — a new number on screen. ## 7. Test it ```sh npm test ``` `tests/game.test.ts` runs the real systems against a mock host — no browser, no renderer, no physics engine — and asserts that W walks the player, that a shot takes health off the enemy in the crosshair, that the win message appears when the last one dies, and that the same seed produces the same frames twice. That is the loop to work in. The browser is for looking at it. ## 8. Ship it ```sh npm run build # guest component + production bundle npm run preview ``` `npm run dev` runs your TypeScript directly; `npm run build` compiles the very same TypeScript into a QuickJS WebAssembly component. The host code is identical and `import.meta.env.GAMEABLE_MODE` is the only difference. If the two behave differently, that is an engine bug — report it rather than working around it. ## See also - [ECS and game code](../concepts/ecs.md) - [The wasm boundary](../concepts/wasm-boundary.md) - [Assets and the manifest](../concepts/assets.md) - [Recipes](../recipes/index.md) # FILE: docs/concepts/wasm-boundary.md # The wasm game-logic boundary All game logic is TypeScript compiled to a QuickJS WebAssembly component. The host owns rendering, physics, input, audio, assets and characters; the guest owns entities, components and systems. They meet at one WIT world, `gameable:engine@0.2.0 / game-module`, and at **one export per frame**. ## The contract The guest exports five functions: | Export | Meaning | | ------------------------ | ------------------------------------------------- | | `init(game-config)` | Once, before the first tick. Seed the RNG here. | | `tick(frame-input)` | One fixed step. Returns `frame-output`. | | `shutdown()` | Release guest resources. No further calls follow. | | `snapshot() -> list` | The whole guest state, opaque to the host. | | `restore(list)` | Read one back, from the same build. | and imports three interfaces: `env` (`log`, `seed`, `now-ms`), `physics-query` (`raycast`, `raycast-batch`, `overlap-sphere` — the only synchronous host calls) and `assets` (`resolve-id`, `describe`, init-time only). Continuous data crosses as packed `list`: - **`frame-input.bodies`**, stride 15: `body-id`, position (3), rotation (4), linear velocity (3), angular velocity (3), character ground state (1). Sorted ascending by body id. - **`frame-output.transforms`**, stride 12: `entity`, `transform-flags`, position (3), rotation (4), scale (3). Only rows whose flags are non-zero. `transforms` carries **guest-authored** moves only. A physics-driven entity is not one: the host reads the body rows when it steps and writes them onto the entity itself, so the same numbers never make the round trip out again. The guest still receives them as `bodies` and still keeps `Transform` and `Velocity` up to date for gameplay — it reads them, it does not own them. A system that writes a transform lane by hand says so with `markMoved(entity)`, and that row does cross. See [Physics](./physics.md). Structural changes cross as a `list` variant — spawn, add-body, play-sound, set-expression and twenty more — applied by the host front to back after the guest returns. Physics queries are the one exception; everything else is deferred, and there is deliberately no per-entity call. ## JavaScript shapes jco lifts and lowers the canonical ABI into plain JavaScript. The shapes differ between the two sides, and getting them wrong is the most common way to break the boundary. | WIT | In the guest (QuickJS) | On the host (V8) | | --------------------------- | -------------------------------------- | -------------------------------- | | `u64` | `number` | `bigint` | | `u32`, `f32`, `f64` | `number` | `number` | | `list`, `list` in | plain `Array` | any `ArrayLike`, typically typed | | `list` out | `Array` or `Float32Array` (20% faster) | `Float32Array` | | `list` | `Uint8Array` | `Uint8Array` | | `option` = none | `null` incoming | `undefined` / absent | | `variant` | `{ tag: 'kebab-case', val }` | same | | `enum` | kebab-case string | same | | `flags` | every key present incoming | every key present | | `record` | object with camelCase fields | same | | exported `result<_, E>` | **throw** the `E` record | `ComponentError` with `.payload` | | exported `interface` | `export const game = { … }` | `root.game` | Four rules fall out of that table, and the SDK enforces all four: 1. **Never `instanceof` or `.subarray()` an incoming list.** The generated guest `.d.ts` claims `Float32Array`; at runtime it is a plain `Array`. The SDK types every incoming list as `ArrayLike`, which makes the mistake a compile error, and copies into preallocated typed arrays. 2. **Never emit a zero-length `list`.** QuickJS returns a pointer of 1 for a zero-length allocation and jco's lifter rejects it with `list pointer [1] is not aligned to 4`. `TransformPacker` emits a single all-zero row instead; the host skips rows whose flags are zero. 3. **`u64` is a `bigint` on the host.** `frame` must be `BigInt()`ed on the way in and `Number()`ed in the guest. `createInputEncoder` and the SDK runtime do both. 4. **Every float is an `f32`.** The SDK rounds with `Math.fround` on the way out and `quantizeInput` rounds on the way in, so direct mode and wasm mode are bit-identical rather than merely similar. ## Two modes, one program `createSandbox` runs the guest either way: - **`mode: 'wasm'`** loads a `jco transpile`d component and calls `instantiate(getCoreModule, imports, WebAssembly.instantiate)`. This is the shipping path. - **`mode: 'direct'`** dynamic-imports the game's TypeScript and runs it through the same `createGuest` runtime in the host's realm. No build step, so this is what `npm run dev` uses. They share the guest runtime on purpose. `tests/boundary/parity.test.ts` drives 300 scripted frames through both and compares `hashFrameOutput` frame by frame; a divergence is a bug in the engine, not a configuration difference. A guest trap **permanently poisons** a component instance — every later call fails with `cannot enter component instance` — so the sandbox latches a `dead` flag on the first failure, returns an inert frame, and leaves it to the host to build a new sandbox. ## Building the guest Three steps, all absolute forward-slashed paths. `fixtures/tiny-game/scripts/build.mjs` is the reference implementation. ```sh # 1. ambient types for the gameable:engine imports (committed, not per build) jco guest-types /wit --world-name game-module -o /src/wit/generated # 2. TypeScript in, component out. Bundled by rolldown automatically. jco componentize --backend qjs --backend-qjs-disable-async \ -n game-module --wit /wit \ --bundle-config /build/rolldown.config.mjs \ -o /build/game.wasm /build/entry.ts # 3. component in, nine core wasm files plus game.js out. No --map. jco transpile /build/game.wasm --instantiation async --no-nodejs-compat \ --name game -o /build/guest ``` Red `UNRESOLVED_IMPORT` warnings for the `gameable:engine/*` specifiers in step 2 are expected noise: componentize resolves them itself, after the bundle. `jco componentize` compiles exactly one module, and that module has to import the versioned `gameable:engine/*@0.2.0` specifiers — which only resolve inside componentize. So the build **generates** a four-line entry per game: ```ts // build/entry.ts — generated import { createGuestExports } from '../../../packages/sdk/src/wit/entry'; import definition from '../src/game'; export const game = createGuestExports(definition); ``` The game module itself never mentions WIT. `createGuestExports` imports the prelude first, adapts the three import interfaces to the SDK's `HostApi`, and wires them to `createGuest`. ## What QuickJS does not have componentize-qjs is QuickJS-NG plus a Wizer heap snapshot. There is no `console`, no `TextEncoder` / `TextDecoder`, no `structuredClone`, no timers and no `crypto`. There _is_ `performance.now`, `Date.now`, `Proxy`, `Promise` and the typed arrays. Worse, **module scope runs during the build-time snapshot**. A `Date.now()` or `Math.random()` at module top level is frozen into the binary, and QuickJS seeds `Math.random` identically on every instantiation. So: > Seed randomness inside `init()`, from `env.seed()`. Never at module scope. `gameable/sdk/prelude` installs `console` (routed at `env.log`), UTF-8 `TextEncoder` / `TextDecoder` polyfills and a `performance.now` fallback, and replaces `Math.random` with a guard that throws a helpful message until the runtime seeds it. In V8 it deliberately leaves `Math.random` alone, because patching the global would reach vitest and the host application too — which is one more reason to use `ctx.rng`. The component imports 18 `wasi:*` interfaces but only ever calls two functions: `wasi:clocks/monotonic-clock#now` and `wasi:clocks/wall-clock#now`. `minimalWasi()` stubs the rest in about 100 lines; the stderr `OutputStream` is the one stub that needs a real write path, because that is where QuickJS writes trap messages. ## Measured cost From `tests/boundary/smoke.test.ts` on the reference machine (node 24, Windows): | Measurement | Value | | ------------------------------------ | -------- | | Instantiate (cold, includes compile) | ~500 ms | | Steady-state tick, p50 | 0.18 ms | | Steady-state tick, p90 | 0.22 ms | | Steady-state tick, p99 | 0.30 ms | | Component size | ~2.1 MiB | A steady-state tick allocates nothing: the transform buffer is preallocated and its subarrays memoised, command objects are pooled per tag, and the frame-output record is reused. ## Other guest languages Nothing above is JavaScript-specific. The world is resource-free and async-free on purpose — records, variants, enums, flags, lists, primitives — which is the subset every `wit-bindgen` backend supports, so a Rust, C or Go guest implements the same five exports and the host cannot tell them apart. `examples/wasm-guest-rust` is the proof, and the Rust path is two steps rather than three: `cargo build --release --target wasm32-wasip2` emits a component by itself — rustc links through `wasm-component-ld`, so there is no `componentize` pass, no generated entry module and no `cargo-component` — and then the same `jco transpile --instantiation async --no-nodejs-compat`. Bindings come from `wit_bindgen::generate!({ path: "../../wit", world: "game-module" })`, pointed at the engine's own WIT, never a vendored copy. The differences that matter: | | QuickJS guest | Rust guest | | ---------------- | ----------------------------- | --------------------------------- | | Component | ~2.1 MiB | ~107 KiB (39 KiB brotli) | | Steady tick p50 | 0.19 ms | 0.07 ms | | `wasi:*` imports | 18 | 5, all covered by `minimalWasi()` | | Guest state | module scope, frozen by Wizer | `thread_local!`, seeded in `init` | Rust cannot fully honour "no allocation in a system": `frame-output.transforms` is a `Vec` the canonical ABI takes by value, so one allocation per frame is structural. Everything else is pre-sized. ## See also - [ECS and game code](./ecs.md) - [Write a guest in Rust](../recipes/write-a-guest-in-rust.md) - [Build the wasm guest](../recipes/build-the-wasm-guest.md) - [Write a game system](../recipes/write-a-game-system.md) - `packages/wasm-host/README.md` — gameable/host - `packages/sdk/README.md` — gameable # FILE: docs/concepts/ecs.md # ECS and game code bitecs 0.4 is the ECS and it lives **inside the guest**. The host owns rendering, physics, input, audio, assets and characters; the guest owns entities, components and systems. `gameable` is the only import game code needs. ## A game is one declaration ```ts import { defineGame, prefab, Enemy, Health } from 'gameable'; const Player = prefab({ body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'character', mass: 80 }, health: 100, }); const Grunt = prefab({ asset: 'enemy-capsule', body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'dynamic', mass: 60 }, health: 30, components: [Enemy], }); export default defineGame({ assets: ['arena', 'enemy-capsule', 'shot'], world: { gravity: -9.81, maxEntities: 512 }, player: { prefab: Player, spawn: [0, 1, 0], camera: 'firstPerson', eyeHeight: 1.7 }, spawns: [{ prefab: Grunt, position: [0, 1, -6] }], rules: { walkSpeed: 4, damage: 10 }, systems: [movePlayer, shoot, updateHud], }); ``` `assets`, `world`, `player` and `spawns` are sugar over built-in systems, so two games that declare the same thing behave identically. `rules` is handed straight back as `ctx.rules`. Anything the declarative layer cannot express is a plain system. ## Tick order Fixed, because determinism depends on it: 1. decode `frame-input` into preallocated storage, and apply `player-joined` / `player-left` (a room's authority spawns or despawns `player` for that player) 2. ingest rigid-body transforms (stride 15) into `Transform` and `Velocity`, to read — the host drew them itself when it stepped, so they are not packed back out 3. built-in look accumulator (`input.mouse.dx/dy` → `camera.look`) 4. built-in velocity integration, for entities with `Velocity` and no `RigidBody` — this is where `world.gravity` applies 5. your `systems[]` that run where this guest runs, in declaration order 6. your `update()` 7. built-in camera rig, from `player.camera` 8. pack `frame-output.transforms` and the command list Each user system is wrapped: a throw is logged and the frame still returns a valid output. Eight consecutive failing ticks mark the guest `dead` and the host rebuilds the sandbox. ## Systems A system is a plain function of the frame context: ```ts import type { GameContext } from 'gameable'; // Reused across frames: a system must not allocate. const eye = { x: 0, y: 0, z: 0 }; function shoot(ctx: GameContext): void { if (!ctx.input.mousePressed(1)) return; eye.x = Transform.x[ctx.player]; eye.y = Transform.y[ctx.player] + 1.7; eye.z = Transform.z[ctx.player]; const hit = ctx.physics.raycast(eye, forward, 100, undefined, ctx.player); if (hit) Health.current[hit.entity] -= 10; } ``` `ctx` carries `world`, `frame`, `dt`, `elapsed`, `rng`, `contacts`, `events`, `player`, `rules`, `config`, the `spawn` / `despawn` / `assetId` helpers, and the facades. It is the same object every frame, mutated in place — read it, do not retain it. ## Built-in components Structure-of-arrays: `Transform.x[entity]`, never `Transform[entity].x`. Every array is preallocated to `world.maxEntities` (default 4096), so writing one never allocates. | Component | Lanes | | ------------ | ---------------------------------- | | `Transform` | `x y z`, `qx qy qz qw`, `sx sy sz` | | `Renderable` | `asset`, `flags`, `dirty` | | `RigidBody` | `handle`, `kind`, `shape`, `dirty` | | `Character` | `bundle`, `dirty` | | `Velocity` | `x y z`, `ax ay az` | | `Health` | `current`, `max` | plus the tags `Player`, `Enemy` and `Pickup`. `createWorld`, `addEntity`, `removeEntity`, `addComponent`, `removeComponent`, `hasComponent`, `query`, `Not`, `Or` and `And` are re-exported from bitecs unchanged, so your own components work exactly as bitecs documents. ### Writing `Transform` by hand `spawn` and the built-in velocity integration tell the packer which entities moved. Nothing watches the arrays themselves, so a system that writes a lane directly has to say so — otherwise the row is never packed and the host never moves the object: ```ts import { Transform, TRANSFORM_FLAGS, markMoved } from 'gameable'; function bob(ctx: GameContext): void { Transform.y[ctx.player] = 1 + Math.sin(ctx.elapsed); markMoved(ctx.player, TRANSFORM_FLAGS.POSITION); } ``` `markMoved(entity)` with no flags marks position, rotation and scale. Flags accumulate until the end of the tick, so marking twice costs nothing. Body ingestion is the deliberate exception: it fills `Transform` and `Velocity` for a physics-driven entity but marks nothing, because the host already moved that object from the body's own row. Read those lanes freely; to _move_ the entity, send `physics.teleport` or `physics.setVelocity` rather than writing the lane, or the body will simply put it back next step. ## Prefabs mint the ids Every handle is minted by the **guest**, so the host never has to hand one back. `prefab()` is pure and safe at module scope; `spawn()` allocates the entity id from bitecs, the body id from a counter that starts at 1, writes the built-in components, and queues the `spawn` / `add-body` / `spawn-character` commands the host needs. ```ts const crate = ctx.spawn(Crate, { x: 0, y: 2, z: -5 }); ctx.despawn(crate); // emits remove-body then despawn ``` ## Zero allocation, and why it matters Systems run 60+ times a second inside a QuickJS heap with a simple collector. A steady-state tick in Gameable Engine allocates nothing: - `frame-output.transforms` is a preallocated `Float32Array`, returned as a memoised subarray — the same row count returns the same object every frame. - Commands are pooled per tag and mutated in place; `commandPoolSize()` stops growing once a game reaches its steady state, and a test asserts it. - `input.axis2(...)` and `input.mouse` return pooled objects. - `hud.set(model)` shallow-compares against the previous model and only calls `JSON.stringify` on a real change. Follow the same rule in your own systems: hoist your vectors, hoist your query term arrays, and mutate. ## Snapshot and restore `snapshot()` serialises the built-in component arrays, the entity id space, the SDK counters and the RNG state into a versioned little-endian byte string. Custom components are not covered automatically — return them from `defineGame({ snapshot })` and read them back in `defineGame({ restore })`, and they are JSON-encoded into the snapshot header. Restore rebuilds the entity id space exactly, so a run resumed from a snapshot produces the same frames as the run it was taken from. The determinism test relies on it. ## See also - [The wasm boundary](./wasm-boundary.md) - [Write a game system](../recipes/write-a-game-system.md) - `packages/sdk/README.md` — gameable - [Build your first FPS](../start/02-first-fps.md) # FILE: packages/sdk/README.md # gameable ## What The guest-side API. `defineGame`, the bitecs re-exports, the built-in SoA components and systems, prefabs, and the `input` / `physics` / `camera` / `hud` / `audio` / `character` facades that marshal across the wasm boundary without allocating. Everything here runs in two places and must behave identically in both: QuickJS inside the compiled component, and V8 in `mode: 'direct'` (the Vite dev server and vitest). ## When to use Always, when you are writing a game. This is the single import a game module needs. You do not import `gameable/core`, `three`, or anything else. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { defineGame, prefab, Health } from 'gameable'; const Player = prefab({ name: 'player', body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'character', mass: 80 }, health: 100, }); const Enemy = prefab({ asset: 'enemy-capsule', body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'dynamic', mass: 60 }, health: 30, }); export default defineGame({ assets: ['arena', 'enemy-capsule', 'shot'], world: { gravity: -9.81 }, player: { prefab: Player, spawn: [0, 1, 0], camera: 'firstPerson' }, spawns: [{ prefab: Enemy, position: [0, 1, -6] }], systems: [ // Walk with WASD, in the camera's yaw frame. (ctx) => { const move = ctx.input.axis2('A', 'D', 'S', 'W'); const yaw = ctx.camera.look.yaw; const vx = (move.x * Math.cos(yaw) - move.y * Math.sin(yaw)) * 4; const vz = (-move.x * Math.sin(yaw) - move.y * Math.cos(yaw)) * 4; ctx.physics.moveCharacter(ctx.player, vx, 0, vz, ctx.input.pressed('Space')); }, // Hitscan on left mouse button. (ctx) => { if (!ctx.input.mousePressed(1)) return; const hit = ctx.physics.raycast(eye, forward, 100, undefined, ctx.player); ctx.audio.play('shot', { entity: ctx.player }); if (hit) Health.current[hit.entity] -= 10; }, // HUD JSON only crosses on the frames it changed. (ctx) => { ctx.hud.set({ health: Health.current[ctx.player] }); }, ], }); const eye = { x: 0, y: 1.7, z: 0 }; const forward = { x: 0, y: 0, z: -1 }; ``` ## API - **`defineGame({ assets, world, player, spawns, rules, systems, init, update, shutdown, snapshot, restore })`** — the declaration a game module default-exports. Declarative fields are sugar over built-in systems that run before yours, in a fixed order. - **`featuresOf(definition)`** — the game's `features` block as a frozen table, name to options (`true` becomes `{}`, `false` is dropped). The host reads it to decide which feature modules to load. - **`roomSeats(definition)`** — the seats a room game declares, `features.multiplayer.maxPlayers` (`DEFAULT_ROOM_SEATS`, 8, for `multiplayer: true`; undefined without the feature). Seats are ids `0..seats - 1`. The guest makes exactly that many player slots and ignores a join past them; `createEngineRoomGame` and `createRoom` read the same number. `roomSendHz` reads `sendHz` (20; 1 to 60). - **`player.spawn`** — one point `[x, y, z]` for every seat, a list of points (seat `i` takes `list[i % n]`), or `(seat, ctx) => ({ x, y, z })`. A single-player game spawns seat 0. The function runs inside the step: keep it deterministic (seat, `ctx.rules`, `ctx.rng`). - **`ctx.players`** — a read-only `Map` from id to `PlayerHandle`, plus `host` (the first joiner, who stays host until they leave or drop; then the lowest seat still joined; undefined with nobody joined; kept by snapshots; also `PlayerHandle.isHost`) and `list`, the same players in id order in one array kept for the whole run. Walk `list` with an index in a system: iterating the map allocates. - **`ctx.data`** — player documents in the room's store, written by the authority. `ctx.players.get(id).data` is the document the room loaded at join (`null` for none), `.savedAt` when the store last wrote it and `.joinedAt` the server's clock at the load (ms since the epoch; offline time is `joinedAt - savedAt`; `savedAt` is `null` with no document, `joinedAt` without a store). `ctx.data.save(id, doc)` replaces it and has the room write it, at most once per 6 s per player and always when the player leaves or the room closes. `ctx.data.exchange(a, b, give, take)` trades all or nothing (`applyTransfer`: numbers move amounts, lists move items; an object item such as a pet matches by value, whatever its key order) and returns an id; its result is in `ctx.data.results()` a tick or more later, and on success both players' `data` already show the trade. `ctx.data.game` and `ctx.data.saveGame(doc)` are the game's own document. On a client every call does nothing (logged once). - **`defineMessage(name, check, { maxBytes })`** — a checked message for `ctx.net.messages(def)` and `ctx.net.send(def, payload)`; a bad payload is dropped and counted in `ctx.net.stats`. - **`prefab(spec)` / `spawn(def, position, rotation?)` / `despawn(entity)`** — entity templates. `spawn` mints the entity and body ids and queues the `spawn` / `add-body` / `spawn-character` commands. Freed body ids are reused, lowest first: ids stay under the live body count (`maxBodies`). - **ECS** — `createWorld`, `addEntity`, `removeEntity`, `addComponent`, `removeComponent`, `hasComponent`, `query`, `Not`/`Or`/`And` re-exported from bitecs 0.4, plus the built-in SoA components `Transform`, `Renderable`, `RigidBody`, `Character`, `Velocity`, `Health` and the tags `Player`, `Enemy`, `Pickup`. `configureEcs(n)` resizes them; the default is 4096. - **Facades** — `input` (`isDown`, `pressed`, `released`, `axis2`, `mouse`, `gamepad`), `physics` (`raycast`, `raycastBatch`, `overlapSphere`, `moveCharacter`, `applyImpulse`, `setVelocity`, `teleport`), `camera` (`firstPerson`, `follow`, `set`, `lookAt`, `look`), `hud` (`set`, `invalidate`, `clear`), `audio` (`play`, `stop`, `listener`), `character` (`setState`, `setClipWeights`, `setExpression`, `lookAt`, `say`). - **`createGuest(host, definition)`** — the runtime behind both sandbox modes. `gameable/host` calls it for you. - **`createRng(seed)`**, `assetId(name)`, `writeSnapshot` / `readSnapshot`, `TransformPacker`, `BodyIndex`, `CommandBuffer`, `commandPoolSize()`. - **`gameable/sdk/keycodes`** — the canonical key table. `gameable/input` imports it so host and guest agree bit for bit. - **`gameable/sdk/prelude`** — the QuickJS compatibility layer, imported first by the componentize entry. `createThirdPersonController(clips?)` shares camera-relative acceleration, shortest-path turning, a configurable idle-turn threshold (default 90 degrees), and contact-driven jump phases. Create it once, call `reset(ctx)` from game init and `update(ctx, frozen?)` from a system. On a room's authority call `updatePlayers(ctx, frozen?)` instead: it moves each player's own entity (the one they possess) from their own input, with their own camera, keeping one state per seat (`stateOf(id)`), reset when the seat's entity changes. Tune movement and camera through `ctx.rules`. Optional `{ idle, walk, run, rise, fall, land }` clip names enable an explicit base blend; the asset bundle must contain all six clips. Otherwise the animator uses its standard speed blend. `physics.isGrounded(entity)` reads the last packed Jolt contact result without a host call. It returns false for unknown, steep, unsupported and airborne states, including a stationary jump apex. The body buffer now has stride 15; rebuild hosts, guests and replay fixtures together. Snapshots use version 2 because the contact lane is persisted; version 1 snapshots are rejected explicitly. ## Gotchas - **One direct guest per JS realm.** The component arrays are shared, so a direct guest started before another throws on its next `tick`. Run one as wasm. A `client` role runs no level `spawns`. - **Seed randomness in `init`, from `env.seed()`.** Wizer snapshots the QuickJS heap at build time, so anything derived from `Math.random()` or `Date.now()` at module scope is frozen into the binary. In the component, `Math.random()` throws until the runtime seeds it; in V8 the prelude leaves the global alone, so a game that calls it there will still be non-deterministic. Use `ctx.rng`. - **Never `instanceof` or `.subarray()` an incoming list.** jco hands the guest a plain `Array` for `list` and `list`; direct mode hands it a `Float32Array`. The types say `ArrayLike` for exactly this reason. - **Never emit a zero-length `list`.** jco's lifter rejects the pointer QuickJS returns for one. `TransformPacker` emits a single all-zero row instead, which the host skips. - **Assets are string ids.** Resolve them in `init`; the SDK caches, and warns when a name is resolved during `tick`. - **No allocation in a system.** `axis2`, `input.mouse` and the command list are pooled objects that are reused every frame. Read them; never retain them. `for (const [id, p] of ctx.players)` makes an iterator and a pair per player: walk `ctx.players.list` by index instead. - **`ctx.data.save` serialises the document.** Call it when the document changed, not every tick. Saves made between an exchange and its result are dropped by the room (the SDK saves both traded documents when the result arrives); a player who leaves inside that window loses those saves, not the trade. - **Key names are case-sensitive**, except that a bare letter or F-key also answers in lower case (`'w'`, `'f1'`). `'shift'` is unknown: write `'Shift'`. - **A bad `features.multiplayer.maxPlayers` fails `init`**, in a single-player run too: anything but a whole number from 1 to 4097 is `init-failed`. - **A dropped host hands the role on while the room holds their seat** (30 seconds on the room server, its catalog entry's `reconnectSeconds`). The room leaves a held seat out of `frame-input.players`, and the SDK reads that as gone for the host role alone: `host` moves to the lowest seat in the list and stays there when the old host is back. The player is still joined (`connected`), and the guest hears `player-left` only when the hold times out. - **Floats cross as `f32`.** The SDK rounds on the way out so direct mode and wasm mode stay bit-identical; do not be surprised when `0.1` comes back as `0.10000000149011612`. # FILE: templates/fps/src/game.ts /** * The game. * * **This is the file to edit.** Everything else in `src/` is scenery: the * prefabs say what things are made of, the systems say what they do, and this * file says which of them exist, where, and with what numbers. * * The declarative fields (`assets`, `world`, `player`) are sugar over built-in * systems, so two games that declare the same thing behave identically. * `rules` is handed straight back as `ctx.rules`, which is where every tuning * number in this template comes from — change one there and nothing else has * to know. * * The whole file runs **inside the wasm guest**. It may not touch the DOM, * fetch anything, or ask for the time; the only way out is a command in * `frame-output`, and the facades on `ctx` build those for you. */ import { defineGame, Health, type GameContext } from 'gameable'; import { arenaSpawns } from './arena'; import { resetHud, updateHud } from './hud'; import { EnemyPrefab, ENEMY_CENTRE, ENEMY_COLOUR, EYE_OFFSET, MedkitPrefab, MEDKIT_COLOUR, PlayerPrefab, PLAYER_CENTRE, tint, } from './prefabs'; import { enemyAI, resetEnemyAI } from './systems/enemyAI'; import { pickups, resetPickups } from './systems/pickups'; import { resetWeapon, weapon } from './systems/weapon'; /** Where the player starts, lifted so the capsule's feet are on the floor. */ const PLAYER_SPAWN: readonly number[] = [ arenaSpawns.player.position[0], arenaSpawns.player.position[1] + PLAYER_CENTRE, arenaSpawns.player.position[2], ]; /** Reused spawn vector: `init` runs once, but the rule is the rule. */ const at = { x: 0, y: 0, z: 0 }; /** * Walk the player, in the direction the camera is facing. * * `moveCharacter` is a request, not a teleport: the host character controller * resolves it against the world and the result comes back next frame in * `Transform`. * * @param ctx The frame context. * @returns Nothing. */ function movePlayer(ctx: GameContext): void { const player = ctx.player; if (player === 0) return; if ((Health.current[player] ?? 0) <= 0) { ctx.physics.moveCharacter(player, 0, 0, 0); return; } const speed = Number(ctx.rules.walkSpeed ?? 5); const move = ctx.input.axis2('A', 'D', 'S', 'W'); const yaw = ctx.camera.look.yaw; const sin = Math.sin(yaw); const cos = Math.cos(yaw); // Forward is -Z, so a yaw of 0 turns (0, 1) into (0, -speed). const vx = (move.x * cos - move.y * sin) * speed; const vz = (-move.x * sin - move.y * cos) * speed; ctx.physics.moveCharacter(player, vx, 0, vz, ctx.input.pressed('Space')); } export default defineGame({ // Optional engine features this game opts into; the page resolves each by name. features: { characters: true }, // Manifest ids, resolved once during init. Never a path, never a URL. assets: ['env.arena', 'char.enemy', 'sfx.shot', 'sfx.hit', 'sfx.pickup', 'sfx.step'], world: { gravity: -9.81, maxEntities: 512 }, player: { prefab: PlayerPrefab, spawn: PLAYER_SPAWN, camera: 'firstPerson', eyeHeight: EYE_OFFSET, sensitivity: 0.0022, }, // Every number the systems read. This is the tuning surface. rules: { walkSpeed: 5, magazine: 12, damage: 20, range: 60, fireInterval: 0.18, reloadTime: 1.1, // Sight is deliberately shorter than the arena is wide: waking all six at // once is a swarm, not a fight. Raise it if you want a harder opening. enemySpeed: 2.2, enemySight: 14, enemyReach: 1.5, enemyDamage: 6, enemyAttackInterval: 1.4, medkitHeal: 35, pickupRadius: 1.2, winMessage: 'ARENA CLEARED', loseMessage: 'YOU DIED', }, init: (ctx) => { resetWeapon(ctx); resetEnemyAI(); resetPickups(); resetHud(); for (const spawn of arenaSpawns.enemies) { at.x = spawn.position[0]; at.y = spawn.position[1] + ENEMY_CENTRE; at.z = spawn.position[2]; tint(ctx.spawn(EnemyPrefab, at), ENEMY_COLOUR[0], ENEMY_COLOUR[1], ENEMY_COLOUR[2]); } for (const spawn of arenaSpawns.pickups) { at.x = spawn.position[0]; at.y = spawn.position[1]; at.z = spawn.position[2]; tint(ctx.spawn(MedkitPrefab, at), MEDKIT_COLOUR[0], MEDKIT_COLOUR[1], MEDKIT_COLOUR[2]); } // The host grabs pointer lock on the first click; asking here makes the // very first frame consistent between the browser and the test harness. ctx.hud.invalidate(); console.log(`arena ready: ${String(arenaSpawns.enemies.length)} enemies`); }, // Run in order, once per fixed step, after the built-ins. systems: [movePlayer, weapon, enemyAI, pickups, updateHud], }); # FILE: templates/fps/src/prefabs.ts /** * What the things in this game are made of. * * A prefab is a pure declaration — it resolves nothing and is safe at module * scope. `ctx.spawn(Prefab, position)` turns one into an entity, writes the * built-in components, and queues the commands the host needs. * * A prop is a box the size of its own physics shape, so changing `dims` * changes what you see as well as what you hit. A prefab with a `character` * draws a real rig instead, standing on the same body's feet. */ import { activeRuntime, Enemy, Pickup, Player, prefab } from 'gameable'; /** Player capsule radius, metres. */ export const PLAYER_RADIUS = 0.35; /** Half-height of the player capsule's cylindrical section, metres. */ export const PLAYER_HALF_HEIGHT = 0.8; /** * Height of the player capsule's centre above its feet. * * A Jolt capsule's origin is its centre, so a spawn point on the floor has to * be lifted by this much or the player starts buried. */ export const PLAYER_CENTRE = PLAYER_RADIUS + PLAYER_HALF_HEIGHT; /** Eye height above the entity origin: 1.7 m above the floor. */ export const EYE_OFFSET = 1.7 - PLAYER_CENTRE; /** Enemy capsule radius, metres. */ export const ENEMY_RADIUS = 0.35; /** Half-height of an enemy capsule's cylindrical section, metres. */ export const ENEMY_HALF_HEIGHT = 0.7; /** Height of an enemy capsule's centre above its feet. */ export const ENEMY_CENTRE = ENEMY_RADIUS + ENEMY_HALF_HEIGHT; /** Half-extent of a medkit box, metres. */ export const MEDKIT_HALF = 0.22; /** * The player: an invisible capsule driven by the host character controller. * * It has no `asset`, and the first-person camera hides whatever entity it is * mounted on, so nothing is drawn where your eyes are. */ export const PlayerPrefab = prefab({ name: 'player', body: { shape: 'capsule', dims: [PLAYER_RADIUS, PLAYER_HALF_HEIGHT], kind: 'character', mass: 80, layer: { player: true }, mask: { staticGeometry: true, enemy: true, pickup: true }, flags: { reportContacts: true, lockRotation: true, noSleep: true }, }, health: 100, components: [Player], }); /** * An enemy: a red person who walks at you and hits you. * * `character` names an entry in `src/assets.json`; the host draws that rig * instead of the capsule and blends its clips from `character.setState`. */ export const EnemyPrefab = prefab({ name: 'enemy', character: 'char.enemy', body: { shape: 'capsule', dims: [ENEMY_RADIUS, ENEMY_HALF_HEIGHT], kind: 'character', mass: 70, layer: { enemy: true }, mask: { staticGeometry: true, player: true, enemy: true }, flags: { reportContacts: true, noSleep: true }, }, health: 40, components: [Enemy], }); /** A medkit: a small green box you walk into. */ export const MedkitPrefab = prefab({ name: 'medkit', body: { shape: 'box', dims: [MEDKIT_HALF, MEDKIT_HALF, MEDKIT_HALF], kind: 'fixed', layer: { pickup: true }, mask: { player: true }, flags: { sensor: true }, }, components: [Pickup], }); /** * Paint an entity: its placeholder mesh, or its character's materials. * * `set-material-param` is a normal frame-output command; there is no facade * for it on `ctx` because a real game sets a material through its asset, not * through a uniform. Call it right after `ctx.spawn`, once, never per frame. * * @param entity The entity to paint. * @param r Red, 0..1. * @param g Green, 0..1. * @param b Blue, 0..1. * @returns Nothing. */ export function tint(entity: number, r: number, g: number, b: number): void { activeRuntime().commands.setMaterialParam(entity, 'color', { tag: 'color', val: { r, g, b, a: 1 }, }); } /** Enemy red. */ export const ENEMY_COLOUR: readonly [number, number, number] = [0.82, 0.18, 0.16]; /** Medkit green. */ export const MEDKIT_COLOUR: readonly [number, number, number] = [0.16, 0.78, 0.4]; # FILE: docs/recipes/add-a-weapon.md # Add a weapon ## Goal The right mouse button fires a shotgun: five pellets in a cone, a slower fire rate, its own ammunition, and its own sound. The rifle on the left button keeps working. ## Files you will edit - `src/systems/shotgun.ts` - `src/game.ts` ## Steps 1. Write the system. Five rays in one `raycastBatch` rather than five `raycast` calls, because each call is a full round trip across the wasm boundary; and every object it needs is hoisted, because this runs sixty times a second. ```ts // src/systems/shotgun.ts import { Health, MOUSE_BUTTONS, Transform, type CollisionLayers, type GameContext, type QueryFilter, type RayQuery, } from 'gameable'; import { EYE_OFFSET } from '../prefabs'; /** Pellets per shot. */ const PELLETS = 5; /** Half-angle of the cone, radians. */ const SPREAD = 0.06; /** Mutable state; `resetShotgun` puts it back. */ export const shotgunState = { shells: 6, cooldown: 0 }; /** What a pellet may hit. The player's own capsule is not on these layers. */ const LAYERS: CollisionLayers = Object.freeze({ enemy: true, staticGeometry: true }); const FILTER: QueryFilter = Object.freeze({ layers: LAYERS, solidOnly: true }); /** One `RayQuery` per pellet, built once and mutated in place. */ const rays: RayQuery[] = Array.from({ length: PELLETS }, () => ({ origin: { x: 0, y: 0, z: 0 }, direction: { x: 0, y: 0, z: -1 }, maxDistance: 25, filter: FILTER, })); /** * Reload the shotgun. Call it from `defineGame({ init })`. * * @returns Nothing. */ export function resetShotgun(): void { shotgunState.shells = 6; shotgunState.cooldown = 0; } /** * Fire a spread of pellets on the right mouse button. * * @param ctx The frame context. * @returns Nothing. */ export function shotgun(ctx: GameContext): void { if (shotgunState.cooldown > 0) shotgunState.cooldown -= ctx.dt; if (!ctx.input.mousePressed(MOUSE_BUTTONS.RIGHT)) return; if (shotgunState.cooldown > 0 || shotgunState.shells <= 0) return; shotgunState.shells -= 1; shotgunState.cooldown = Number(ctx.rules.shotgunInterval ?? 0.8); ctx.audio.play('sfx.shot', { entity: ctx.player, volume: 1 }); const player = ctx.player; const yaw = ctx.camera.look.yaw; const pitch = ctx.camera.look.pitch; for (let i = 0; i < PELLETS; i += 1) { const ray = rays[i]; // Deterministic spread: `ctx.rng`, never `Math.random`. const dYaw = yaw + (ctx.rng.float() - 0.5) * SPREAD * 2; const dPitch = pitch + (ctx.rng.float() - 0.5) * SPREAD * 2; const cosPitch = Math.cos(dPitch); ray.origin.x = Transform.x[player]; ray.origin.y = Transform.y[player] + EYE_OFFSET; ray.origin.z = Transform.z[player]; ray.direction.x = -Math.sin(dYaw) * cosPitch; ray.direction.y = Math.sin(dPitch); ray.direction.z = -Math.cos(dYaw) * cosPitch; } const damage = Number(ctx.rules.shotgunDamage ?? 9); const hits = ctx.physics.raycastBatch(rays); for (const hit of hits) { if (!hit) continue; const target = hit.entity; if (target === 0 || (Health.max[target] ?? 0) <= 0) continue; Health.current[target] = (Health.current[target] ?? 0) - damage; ctx.audio.play('sfx.hit', { entity: target, volume: 0.8 }); } } ``` 2. Register it, and give it its numbers. Systems run in declaration order, after the built-ins. ```diff // src/game.ts +import { resetShotgun, shotgun } from './systems/shotgun'; export default defineGame({ rules: { walkSpeed: 5, + shotgunDamage: 9, + shotgunInterval: 0.8, }, init: (ctx) => { resetWeapon(ctx); + resetShotgun(); }, - systems: [movePlayer, weapon, enemyAI, pickups, updateHud], + systems: [movePlayer, weapon, shotgun, enemyAI, pickups, updateHud], }); ``` ## Verify ```sh npm run dev ``` Right-click an enemy at close range: it should die in two shells where the rifle takes two hits. Then check the numbers rather than your eyes — add this to `tests/game.test.ts`: ```ts it('the shotgun hits with every pellet at point-blank range', () => { const harness = boot(); const target = livingEnemies(harness.guest)[0]; scriptedHit = { body: RigidBody.handle[target] ?? 0, entity: target, point: { x: 0, y: 1, z: -2 }, normal: { x: 0, y: 0, z: 1 }, distance: 2, }; pressMouse(harness.input, 2); // MOUSE_BUTTONS.RIGHT harness.step(1); expect(Health.current[target]).toBe(40 - 9 * 5); }); ``` `npm test` must stay green, including the determinism test — if it fails, you used `Math.random()` somewhere instead of `ctx.rng`. ## See also - [Write a game system](./write-a-game-system.md) - [Add an enemy](./add-an-enemy.md) - [ECS and game code](../concepts/ecs.md) - `packages/sdk/README.md` — gameable # FILE: docs/recipes/add-an-enemy.md # Add an enemy ## Goal A second kind of enemy — a heavy: slower, twice the health, hits harder, and a different colour — sharing the existing chase-and-attack behaviour. ## Files you will edit - `src/prefabs.ts` - `src/game.ts` ## Steps 1. Declare what a heavy is made of. A prefab is pure and safe at module scope; nothing is resolved until it is spawned. ```ts // src/prefabs.ts /** A heavy: bigger, slower, and it takes four rifle magazines to notice. */ export const HeavyPrefab = prefab({ name: 'heavy', body: { shape: 'capsule', dims: [0.5, 0.9], kind: 'character', mass: 140, layer: { enemy: true }, mask: { staticGeometry: true, player: true, enemy: true }, flags: { reportContacts: true, noSleep: true }, }, health: 120, components: [Enemy], }); /** Height of a heavy capsule's centre above its feet. */ export const HEAVY_CENTRE = 0.5 + 0.9; /** Heavy purple. */ export const HEAVY_COLOUR: readonly [number, number, number] = [0.44, 0.2, 0.62]; ``` The placeholder mesh is drawn from the body, so the capsule on screen is exactly the capsule the solver uses. Changing `dims` changes both. `reportContacts` is off unless a body asks for it, and a heavy is exactly the kind of body that should ask: the game reacts when one touches the player. `begin` and `end` are all it gets — the per-step `stay` manifold is a separate, rarer opt-in, and nothing here needs it. 2. Spawn two of them, and let the existing AI drive them. `HeavyPrefab` carries the `Enemy` tag, so `enemyAI` already queries it and `updateHud` already counts it — nothing else has to change. ```diff // src/game.ts -import { EnemyPrefab, ENEMY_CENTRE, ENEMY_COLOUR, ... } from './prefabs'; +import { + EnemyPrefab, + ENEMY_CENTRE, + ENEMY_COLOUR, + HeavyPrefab, + HEAVY_CENTRE, + HEAVY_COLOUR, + ... +} from './prefabs'; init: (ctx) => { for (const spawn of arenaSpawns.enemies) { at.x = spawn.position[0]; at.y = spawn.position[1] + ENEMY_CENTRE; at.z = spawn.position[2]; tint(ctx.spawn(EnemyPrefab, at), ENEMY_COLOUR[0], ENEMY_COLOUR[1], ENEMY_COLOUR[2]); } + + // Two heavies, in the far corners. + for (const spawn of [arenaSpawns.enemies[0], arenaSpawns.enemies[1]]) { + at.x = spawn.position[0]; + at.y = spawn.position[1] + HEAVY_CENTRE; + at.z = spawn.position[2] - 2; + tint(ctx.spawn(HeavyPrefab, at), HEAVY_COLOUR[0], HEAVY_COLOUR[1], HEAVY_COLOUR[2]); + } }, ``` To make heavies genuinely slower rather than merely tougher, give them their own speed in `enemyAI` — the state arrays there are indexed by entity id, so a `aiSpeed: Float32Array` written at spawn time is the natural place. ## Verify ```sh npm test ``` The template's own test asserts six enemies; update it to eight and it should pass again: ```ts expect(livingEnemies(harness.guest)).toHaveLength(8); ``` Then `npm run dev`: the HUD should read `enemies 8` at the start, and the two purple capsules should be visibly wider than the red ones and take five rifle hits instead of two. ## See also - [Add a weapon](./add-a-weapon.md) - [Write a game system](./write-a-game-system.md) - [Physics](../concepts/physics.md) - `packages/sdk/README.md` — gameable # FILE: docs/recipes/change-the-level.md # Change the level ## Goal The game renders your own gaussian-splat environment, collides with your own collision mesh, and spawns the player, the enemies and the pickups where you want them. ## Files you will edit - `src/assets.json` - `src/arena.ts` ## Steps 1. Point the manifest at your world. Drop the files in `public/` — Vite copies that directory verbatim — and give them ids. The id is the contract; game code never sees a path. ```diff // src/assets.json { "version": 1, "baseUrl": "/", "assets": [ { "id": "env.arena", "type": "splat", - "src": "@placeholder/arena.spz", + "src": "/levels/foundry.spz", "tags": ["world"], - "collider": { "shape": "mesh", "src": "@placeholder/arena.collider.bin", "layer": "static" } + "collider": { "shape": "mesh", "src": "/levels/foundry.collider.bin", "layer": "static" } }, ``` `@placeholder/...` is a template convention: `src/main.ts` maps those onto the files inside `gameable/placeholder`. Anything else is resolved against `baseUrl`, so `/levels/foundry.spz` means `public/levels/foundry.spz`. SPZ loads about four times faster than PLY; prefer it. The collider is a separate baked triangle mesh, because a splat has no geometry a solver can use — `parseCollider` in `gameable/placeholder` documents the format, and `src/main.ts` is where it is turned into a static body. 2. Move the spawn points. Every `position` is on the floor, in metres, Y-up, with `yaw` in radians about `+Y` (`0` looks down `-Z`); the prefab's own half-height is added when it spawns. ```diff // src/arena.ts export const arenaSpawns: ArenaSpawns = { - bounds: { min: [-12, 0, -12], max: [12, 3, 12] }, - player: { position: [0, 0, 9], yaw: 0 }, + bounds: { min: [-20, 0, -14], max: [20, 6, 14] }, + player: { position: [-17, 0, 0], yaw: 1.5708 }, enemies: [ - { position: [-9.5, 0, -9.5], yaw: -2.3562 }, + { position: [12, 0, -6], yaw: 3.1416 }, + { position: [12, 0, 6], yaw: 3.1416 }, + { position: [0, 0, 0], yaw: 3.1416 }, ], pickups: [ - { position: [0, 1, 0], yaw: 0 }, + { position: [-4, 1, 8], yaw: 0 }, ], }; ``` The loops in `src/game.ts`'s `init` walk these lists, so adding or removing an entry changes how many things exist. Nothing else needs to know. ## Verify ```sh npm test ``` The template ships one test asserting that `src/arena.ts` matches the placeholder pack's own spawn table; delete it once the level is yours, and replace it with one that matters — that every spawn point is inside `bounds`, for example. Then: ```sh npm run dev ``` Walk to each spawn point. You should land on the floor rather than falling through it or standing in the air: if you fall, the collider and the splat disagree about where the floor is, and the collider is what to fix. ## See also - [Assets and the manifest](../concepts/assets.md) - [Load a splat environment](./load-a-splat-environment.md) - [Use the placeholder assets](./use-the-placeholder-assets.md) - `packages/splat/README.md` — gameable/splat # FILE: docs/recipes/add-a-hud-element.md # Add a HUD element ## Goal The HUD shows a new value — a score that goes up when you kill something — next to the ammunition and the enemy count, and it is only sent across the wasm boundary on the frames it actually changed. ## Files you will edit - `src/hud.ts` - `src/systems/weapon.ts` ## Steps 1. Count the kills. The weapon already knows when a hit lands; give it a counter and reset it where every other piece of module state is reset. ```diff // src/systems/weapon.ts export const weaponState = { ammo: 0, cooldown: 0, reloading: 0, hits: 0, + /** Points, for the HUD. */ + score: 0, }; export function resetWeapon(ctx: GameContext): void { weaponState.ammo = Number(ctx.rules.magazine ?? 12); weaponState.cooldown = 0; weaponState.reloading = 0; weaponState.hits = 0; + weaponState.score = 0; } ``` ```diff Health.current[target] = (Health.current[target] ?? 0) - damage; weaponState.hits += 1; + if ((Health.current[target] ?? 0) <= 0) { + weaponState.score += Number(ctx.rules.killScore ?? 100); + } ctx.audio.play('sfx.hit', { entity: target, volume: 0.8 }); ``` 2. Draw it, and add it to the change check. This is the part that matters: `hud.set` compares the model **one level deep** with `Object.is`, so a nested object mutated in place looks unchanged and is never sent. The template keeps a flat mirror of the values it draws and rebuilds the nested model only when one of them moved. ```diff // src/hud.ts -const mirror: { health: number; ammo: number; enemies: number; message: string | null } = { +const mirror: { + health: number; + ammo: number; + enemies: number; + score: number; + message: string | null; +} = { health: -1, ammo: -1, enemies: -1, + score: -1, message: null, }; export function resetHud(): void { mirror.health = -1; mirror.ammo = -1; mirror.enemies = -1; + mirror.score = -1; mirror.message = null; } ``` ```diff const enemies = enemiesLeft(ctx); + const score = weaponState.score; if ( health === mirror.health && ammo === mirror.ammo && enemies === mirror.enemies && + score === mirror.score && message === mirror.message ) { return; } mirror.health = health; mirror.ammo = ammo; mirror.enemies = enemies; + mirror.score = score; mirror.message = message; ctx.hud.set({ text: { ammo: ammo < 0 ? 'reloading' : String(ammo), enemies: String(enemies), + score: String(score), }, bars: { health: { value: health, max: maximum } }, crosshair: health > 0, message, }); ``` The default renderer understands four keys: `text` (label/value rows), `bars` (`{ value, max }` meters), `crosshair` and `message`. Anything else is ignored, so a game that wants its own look passes its own renderer to `createEngineAdapter({ hud })` in `src/main.ts`. ## Verify ```sh npm test ``` Add a test that the score reaches the HUD, and — just as important — that an unchanged frame sends nothing: ```ts it('puts the score on the HUD, and only when it changed', () => { const harness = boot(); const target = livingEnemies(harness.guest)[0]; scriptedHit = { body: RigidBody.handle[target] ?? 0, entity: target, point: { x: 0, y: 1, z: -5 }, normal: { x: 0, y: 0, z: 1 }, distance: 5, }; pressMouse(harness.input, 1); harness.step(60); releaseMouse(harness.input, 1); const payloads: string[] = []; for (let i = 0; i < 10; i += 1) { const out = harness.guest.tick( createFrameInput({ frame: 900 + i, input: harness.input, bodies: new Float32Array(0) }), ); if (out.hud !== undefined) payloads.push(out.hud); } expect(payloads).toHaveLength(0); // nothing changed, nothing crossed expect(weaponState.score).toBeGreaterThan(0); }); ``` Then `npm run dev`: `score 100` should appear top-left when the first enemy dies, and the frame counter in the debug overlay (F3) should not move when you stand still. ## See also - [Write a game system](./write-a-game-system.md) - [Add a weapon](./add-a-weapon.md) - [The wasm boundary](../concepts/wasm-boundary.md) - `packages/wasm-host/README.md` — gameable/host