# 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`. # Generated by tools/docs/gen-llms.mjs. Do not edit. Multiplayer: see llms-multiplayer.txt Characters: see llms-character.txt The mystery kit: see llms-mystery.txt The survive kit: see llms-survive.txt Hangout kit: see llms-hangout.txt Brawl kit: see llms-brawl.txt Steal kit: see llms-steal.txt The collect kit: see llms-collect.txt # 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/index.md --- title: Gameable Engine --- # Gameable Engine Gameable Engine is a browser game engine where the world is a gaussian splat, the renderer is WebGPU, and **all game logic is a WebAssembly component**. ## The 60-second quickstart ```sh npm create gameable my-game -- --template fps cd my-game npm run dev ``` That gives you a splat arena you can walk around, three capsule enemies you can shoot, an ammo and health HUD, and a win condition — with zero edits and no Git LFS. [Install](./start/01-install.md) has the requirements and the options. Then open `src/game.ts`. It is one `defineGame` call: ```ts import { defineGame, input, physics, hud } from 'gameable'; export default defineGame({ assets: './assets.json', world: 'arena', player: { spawn: [0, 1.7, 0], speed: 5, jump: 4.5 }, systems: [ (ctx) => { if (input.pressed('fire')) { const hit = physics.raycast(ctx.camera.origin, ctx.camera.forward, 100); if (hit) ctx.damage(hit.entity, 25); } hud.set({ ammo: ctx.player.ammo, health: ctx.player.health }); }, ], }); ``` Edit it, save, and the guest hot-reloads through a snapshot/restore cycle. That is the whole loop. ## Where to go next | You want to | Read | | ---------------------------------- | ----------------------------------------------------- | | Play the examples in your browser | [Play](./play.md) | | Install the toolchain | [Install](./start/01-install.md) | | Build a first-person game | [Build your first FPS](./start/02-first-fps.md) | | Put a studio character in three.js | [In your three.js app](./three/gameable-character.md) | | See the whole architecture at once | [Concepts](./concepts/index.md) | | Understand how a frame works | [The engine loop](./concepts/engine-loop.md) | | Understand the wasm split | [The wasm boundary](./concepts/wasm-boundary.md) | | Do one specific thing | [Recipes](./recipes/index.md) | | Look up a function | [API reference](./api/index.md) | | Fix an error message | [Troubleshooting](./troubleshooting.md) | | Decode the jargon | [Glossary](./glossary.md) | ## If you are a language model Read [llms-fps.txt](/llms-fps.txt) or [llms-third-person.txt](/llms-third-person.txt) before writing any game code. They are task-scoped bundles built for exactly this. The core corpus is [llms-full.txt](/llms-full.txt) (rooms and splat characters have bundles of their own, which it points to); the index is [llms.txt](/llms.txt). These files also live at the repository root. The hard rules are in `AGENTS.md`. # FILE: docs/start/01-hello-world.md # Hello world with your Gameable character Export a finished character from the [Gameable studio](https://app.gameable.com), import it into `examples/wasm-hello`, then wave and talk to it. The guest spawns the character by asset id. The host animates the exported splats, displays render FPS and connects Voxy microphone input to the character's spoken replies. ## Before you start Follow [Install](./01-install.md) to set up the engine checkout with Node 24+ and `npm install`. You also need a finished character in the Gameable studio and a WebGPU-enabled browser. This character format requires WebGPU in both direct and wasm mode. The hosted [hello-world example](https://engine.gameable.com/play/wasm-hello/) runs a demo character. The public repository does not include one: follow the export steps below to bring your own. ## 1. Export from the Gameable studio 1. Open your character in the Gameable studio and select the head version you want to use. 2. Finish the body and merge the selected head and body. Apply any body adjustments before merging. If you change the head version or body adjustments, merge again before exporting. 3. On the finished character, choose **Export**. The companion fits the rig, bakes the face and binds the splats. Wait for **Export ready** and the ZIP download. Progress and errors appear beside the button; a disabled Export button means the selected head and body still need merging. 4. Extract the complete ZIP into a folder. Use the folder containing `character.json`, rather than a parent folder created by your unzip tool. The export contains: | File | Purpose | | -------------------------------------- | ------------------------------------------------------------ | | `character.json` | Descriptor, relative file paths and SHA-256 hashes | | `character.ply` | Merged character's original splats | | `rig.glb` | Fitted body skeleton and `idle`, `walk`, `run`, `wave` clips | | `head.aosrig` | Fitted face data | | `bindings.bin` | Splats' body and face bindings | | `assets.json` | Manifest entry for importing into a game | | `NOTICE.md`, `README.md`, `example.ts` | Notices and loading example | Keep these files together, including the notices. A standalone PLY or mesh export does not contain the rig and bindings needed to animate this character. ## 2. Import the character From the **engine repository root**, run this command with your extracted folder (quote paths containing spaces): ```sh npm run import:character -w examples/wasm-hello -- "/absolute/path/to/extracted-character" ``` On Windows, `"C:/Users/you/Downloads/my-character"` works in cmd and Git Bash. The importer checks the descriptor's file hashes and copies the export, including notices, into `examples/wasm-hello/public/characters/greeter/`. Importing again replaces the bundled demo there. These files are tracked with Git LFS so clean deployment checkouts include the character. Vite copies them into production builds, so retain the export's notices and distribution restrictions when sharing a build. ## 3. Run hello world ```sh npm run dev -w examples/wasm-hello ``` Open `http://localhost:5180`. Your exported character should appear with **Made with Gameable** branding, the Gameable logo, an FPS counter and a status reading **Gameable character**. Click **Wave** to try its animation. Loading can take a while for a large export. The card reports errors if a file is missing, a hash disagrees or WebGPU is unavailable. ## 4. Follow the asset into the guest `examples/wasm-hello/src/main.ts` registers the exported descriptor: ```ts const manifest = parseManifest({ version: 1, assets: [ { id: 'char.greeter', type: 'character', src: `${import.meta.env.BASE_URL}characters/greeter/character.json`, tags: ['character'], rig: { backend: 'aosrig-splat' }, }, ], }); ``` This adapts the ZIP's `assets.json` entry: `char.exported` becomes the example's `char.greeter`, and `src` points into the imported folder. `BASE_URL` keeps the path working under `/play/wasm-hello/`. The descriptor's own file paths remain relative to `character.json`; do not edit them or its hashes. The host installs `splat()` and creates the character bridge. In `examples/wasm-hello/src/game.ts`, the guest refers only to the asset id: ```ts const Greeter = prefab({ name: 'greeter', character: 'char.greeter' }); // Inside init(ctx): greeter = ctx.spawn(Greeter, { x: 0, y: 0, z: 0 }); character.setClipWeights(greeter, ['idle', 'wave'], [1, 0], 1); ``` Those initial commands cross the sandbox boundary once. The host continues animating while the guest handles conversation and Wave events. The example preallocates the clip arrays outside `update`; no animation command is sent every idle frame. Import a different character and reload to use the same guest. ## 5. Build and verify the wasm version ```sh npm test -w examples/wasm-hello npm run build -w examples/wasm-hello npm run preview -w examples/wasm-hello ``` Open `http://localhost:4180`. The same character, FPS counter and controls now run with the compiled wasm guest. The headless tests check spawn, animation transitions and conversation routing; checking the browser verifies that the actual exported character loads. Import before building, and rebuild after replacing the character. To build the docs site's playable version, use `node tools/docs/build-games.mjs wasm-hello`. If the character does not appear, read the status card and browser console. Re-extract and re-import the complete ZIP for missing files or hash mismatches; enable WebGPU for this format. See [Troubleshooting](../troubleshooting.md) for toolchain issues. ## 6. Enable voice and text conversation Talking characters run on Gameable's hosted conversation and transcription services. Copy `examples/wasm-hello/.env.example` to `.env.local` beside it and set `GAMEABLE_API_KEY` to the API key from your [Gameable account](https://app.gameable.com). It is a server credential; never put it in a `VITE_` variable or game source. From the engine root: ```sh npm run provision -w examples/wasm-hello npm run relay -w examples/wasm-hello ``` Provision installs the included `engine-hello` guide story in your account. Leave the relay running and start dev or preview in a second terminal. Both proxy `/services/hello/` to port 8788. Click **Start conversation**, then type a question or turn **Microphone on**. Allow microphone access to speak. Voxy shows a waveform from actual input; replies carry the character's voice and facial animation. **Interrupt** stops a reply; **End conversation** disconnects and stops capture. You can still type if microphone permission is denied. Use HTTPS or localhost for microphone access. For another browser origin, add it explicitly to `HELLO_ORIGINS`. The docs container includes the relay and needs the same key at runtime; without it, character playback and Wave work but conversation cannot connect. The browser receives no service keys. Next: [your first FPS](./02-first-fps.md), [your first adventure](./03-first-adventure.md), or [how the wasm boundary works](../concepts/wasm-boundary.md). # FILE: docs/start/01-install.md # Install ## What you need | Requirement | Version | Why | | ---------------- | -------------- | ------------------------------------------------------------------- | | Node.js | >= 24 | Pinned in `.nvmrc`; `engine-strict=true` will refuse older versions | | npm | >= 11 | Workspaces and `overrides` | | A modern browser | WebGPU enabled | The renderer. WebGL is a fallback for splat worlds only | | Git | any recent | `create-gameable` runs `git init` for you | Git LFS is **not** required to run a generated game, and not required to run this repository's tests. It is only needed if you add real binary content of your own. Nothing else needs installing by hand. `jco`, `componentize-qjs` and its native binding arrive with the game's own `npm install`, and `gameable doctor` tells you if one of them did not. ## Start a game ```sh npm create gameable my-game -- --template fps cd my-game npm run dev ``` That is a playable game: walk, shoot, kill three capsules, win. Open `http://localhost:5173`, then edit `src/game.ts` and save. `--template third-person` gives you the other scaffold, and `--third-person` is a shorthand for it. ### Options | Flag | Does | | ------------------- | ------------------------------------------------------------------ | | `--template ` | `fps` (default) or `third-person` | | `--third-person` | shorthand for `--template third-person` | | `--title ""` | human title for the page and the README; defaults to the directory | | `--no-install` | skip `npm install` | | `--no-git` | skip `git init` | | `--aam` | add the AvatarOS Asset Manager keys to `.env.example` | | `--force` | write into a directory that already has files in it | With no arguments at all, and an interactive terminal, it asks for the directory and the template. In CI — or when an agent runs it — it takes the defaults and never blocks on a question nobody can answer. The `--` before the flags is npm's, not ours: without it npm eats them. ## What you get ``` my-game/ ├─ AGENTS.md the rules, scoped to this game ├─ .env.example copy to .env.local; VITE_-prefixed keys only ├─ index.html ├─ package.json dev / build / preview / test ├─ public/ served at the site root ├─ src/ │ ├─ game.ts the one defineGame call. Start here │ ├─ assets.json asset ids to files. The ids are the contract │ ├─ prefabs.ts entity templates │ ├─ hud.ts │ └─ systems/ one file per behaviour ├─ tests/ └─ vite.config.ts ``` `.gameable/` and `dist/` appear when you build, and both are gitignored. ## The commands The template's npm scripts wrap [`gameable/cli`](../api/@gameable.cli.md): | Command | Does | | --------------------------- | -------------------------------------------------------- | | `npm run dev` | Vite in direct mode; edit `src/game.ts` and save | | `npm run dev -- --wasm` | build the component first and serve it, to check parity | | `npm run build` | componentize the guest, then `vite build` | | `npm run build -- --report` | the same, plus size and timing numbers, with budgets | | `npm test` | the smoke spec, headless | | `npx gameable doctor` | check the toolchain; exit code is the number of failures | | `npx gameable docs` | where the `llms*.txt` bundles are on this machine | Direct mode is the default because a `jco componentize` run is about thirty seconds and that is not a dev loop. The two modes share the same guest runtime on purpose, and a parity test hashes both — if they diverge, that is an engine bug, not a configuration difference. ## Verify ```sh node --version # v24.x npx gameable doctor # 0 failures npm run dev # http://localhost:5173 ``` `doctor` checks node, npm, a single `three` instance, `src/assets.json` and the files it references, the `gameable:engine` WIT package, jco, `componentize-qjs` and its native binding for your platform. Every failure prints a copy-pasteable fix. It cannot probe WebGPU from node, so it always prints the Chrome flags and leaves that one to you. If something is red, [Debug with doctor](../recipes/debug-with-doctor.md) walks through the failures one at a time. ## Run from a checkout To work on the engine and a game together, run the scaffolder from a clone of the engine; the game it makes links back to that clone: ```sh git clone https://github.com/getgameable/gameable-engine.git gameable cd gameable npm install node packages/create-gameable/bin/create-gameable.mjs ../my-game --template fps ``` `npm install` is the only build step: every package declares a `gameable-source` export condition, so the game resolves the engine straight from `packages/*/src` with `file:` dependencies, and an engine change shows up in the game without a publish. The two templates also run in place, with no scaffolding at all: ```sh npm run dev -w templates/fps # http://localhost:5179 npm run dev -w templates/third-person # http://localhost:5181 ``` ## Next - [Hello world with your Gameable character](./01-hello-world.md) - [Build your first FPS](./02-first-fps.md) - [Ship it](./04-deploy.md) - [The engine loop](../concepts/engine-loop.md) - [Troubleshooting](../troubleshooting.md) ## Test a standalone consumer before publishing Use Node 24 or newer. Build the engine and create ordinary npm tarballs: ```sh npm install npm run build node tools/pack-consumer.mjs ../gameable-packages ``` Run the packed scaffolder outside the workspace (replace the version when it changes): ```sh npm exec --package ../gameable-packages/create-gameable-0.0.0.tgz -- create-gameable ../my-adventure --template third-person --packages-dir ../gameable-packages ``` The generated project depends on the `gameable` tarball by an exact file path. It selects built package exports, including the packaged WIT and component entry, and carries its own lockfile and test runner. Keep the tarballs at the recorded relative paths or move them into the game's own `vendor` directory before installation. No workspace links or aliases to engine sources are needed. Add optional voice/conversation packages explicitly; scaffolding does not activate them. # 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/start/03-first-adventure.md # Build your first adventure Twenty minutes from nothing to a third-person game you can play, and one file to change. ## 1. Scaffold ```sh npm create gameable my-adventure -- --template third-person cd my-adventure npm run dev ``` Open the page, click the canvas, and play: WASD to move, shift to run, space to jump, the mouse to swing the camera, `E` to interact, `1` and `2` to answer a question. A skinned character walks around a gaussian-splat arena with the camera on a boom behind it; two people will talk to you, two chests will open, one of them has the key, and the door in the north wall opens when you bring it. 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/third-person ``` ## 2. What the template contains ``` my-adventure/ ├─ 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, and the tags that say what they are │ ├─ arena.ts where things spawn │ ├─ dialogue.json what people say │ ├─ hud.ts what the HUD shows │ ├─ assets.json asset ids to files │ └─ systems/ │ ├─ locomotion.ts idle -> walk -> run -> jump -> fall, and the orbit camera │ ├─ interact.ts the E prompt, chests, the key, the door │ └─ dialogue.ts walking a conversation, expressions, gaze ├─ tests/game.test.ts the whole game, driven headlessly └─ AGENTS.md these rules, scoped to your game ``` `game.ts` and everything it imports are compiled into the **wasm guest**. It may not touch the DOM, fetch anything, or ask for the time. `main.ts` and the host files it loads stay in the browser. That is the same split as [the FPS template](./02-first-fps.md); if you have read that page, skip to section 4. ## 3. The shape of `game.ts` ```ts import { defineGame } from 'gameable'; export default defineGame({ assets: ['env.arena', 'char.guide', 'sfx.key', 'sfx.door', 'sfx.talk', 'sfx.step'], world: { gravity: -9.81, maxEntities: 256 }, player: { prefab: HeroPrefab, spawn: HERO_SPAWN, camera: 'thirdPerson', distance: 4.5, height: 1.5, }, spawns: [ place(GuidePrefab, arenaSpawns.npcs[0], NPC_CENTRE), place(WandererPrefab, arenaSpawns.npcs[1], NPC_CENTRE), place(KeyChestPrefab, arenaSpawns.chests[0], CHEST_CENTRE), place(ChestPrefab, arenaSpawns.chests[1], CHEST_CENTRE), place(DoorPrefab, arenaSpawns.door, DOOR_CENTRE), ], rules: { walkSpeed: 1.6, runSpeed: 4, cameraDistance: 4.5, interactRange: 2, doorTravel: 2.4, winMessage: 'You escaped', }, systems: [locomotionSystem, interactSystem, dialogueSystem, updateHud], }); ``` The level is a `spawns` list rather than a loop in `init`, and that is worth a sentence. Every prefab says what it is with **tags** — `Interactable` plus one of `Chest`, `Door`, `Npc`, `Key` — so nothing has to be looked up and patched after the spawn. `classifyInteractables` turns those tags into one integer per entity once, during `init`, and the interact system reads that integer instead of asking the ECS four questions sixty times a second. ## 4. The camera is the interesting part In a first-person game the camera is the player's eyes. In a third-person game it is a machine, and the guest does not own it. `src/systems/locomotion.ts` states an intent: ```ts ctx.camera.follow(hero, { yaw, pitch, distance: 4.5, height: 1.5 }); ``` That writes one `camera-state` record: the orbit pivot, the direction the player is looking, and how long the boom should be. The **host** builds a spring arm from it, casts a ray from the pivot towards where the camera wants to sit, and pulls the boom in to just short of whatever it hits. You never write the collision, and the guest never learns the geometry of your level. Two consequences worth knowing: 1. **Forward is away from the camera.** That is why one system owns both the walk direction and the orbit — they are the same number. 2. **The declarative rig re-states the camera after your systems run**, out of the built-in look accumulator. `orbit()` clamps `ctx.camera.look.pitch` in place so the two cannot disagree. Copy that pattern if you take the pitch somewhere else. ## 5. A state machine, in outline ```ts locomotionState.state = grounded ? planar < STILL_SPEED ? IDLE : running ? RUN : WALK : wantsJump || vy > 0 ? JUMP : FALL; character.setState(hero, locomotionState.state, vx, vy, vz, grounded); ``` `grounded` comes from the vertical velocity the host wrote into this frame's body rows, plus a short lock after a jump is requested — a jump is a request, not a teleport, and the upward speed only arrives on the next frame. `character.setState` is the line that matters. It is the contract the character's animator blends from: the planar speed picks idle, walk or run, and the name is your own vocabulary. The hero prefab's `character: 'char.hero'` is what puts the sample rig on the capsule's body; the NPCs' `character: 'char.guide'` does the same, and the `setExpression` and `lookAt` the dialogue system sends per line are recorded against them. **Nothing in `src/` changes when a splat character replaces the mesh.** ## 6. Talking to people `src/dialogue.json` is data: ```json { "guide": { "speaker": "Guide", "lines": [{ "text": "You are awake. Good.", "face": "smile" }], "question": { "text": "Shall I tell you which chest?", "face": "smile", "yes": { "text": "The one in the middle of the floor.", "face": "smile" }, "no": { "text": "Suit yourself.", "face": "neutral" } } } } ``` A JSON import is bundled into the wasm guest along with everything else, so this file ships in the component; there is no fetch and no loading state. Which script an NPC runs is a tag on its prefab, so adding a character with something new to say is an entry in this file and a prefab, and no change to the system that walks it. While anyone is talking, `dialogueState.active` is true and the locomotion system stops driving the hero. You cannot walk away mid-sentence. ## 7. Change something Pick one; each is a recipe of its own: - [Add an interactable](../recipes/add-an-interactable.md) — a lever, in two files. - [Add NPC dialogue](../recipes/add-npc-dialogue.md) — a third person with their own script. - [Tune the follow camera](../recipes/tune-the-follow-camera.md) — boom, height, pitch range, shoulder. - [Add a locomotion state](../recipes/add-a-locomotion-state.md) — a crouch, reported to the animator. ## 8. Test it ```sh npm test ``` `tests/game.test.ts` runs the real systems against a mock host — no browser, no renderer, no physics engine. It walks the hero at both speeds, jumps it, checks that the camera rides a boom behind it, opens a chest, refuses the door without the key and opens it with one, takes both branches of the guide's question, asserts that the character commands go out, and checks that the same seed produces the same frames twice. That is the loop to work in. The browser is for looking at it. ## 9. 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 — `src/dialogue.json` included — 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 - [Build your first FPS](./02-first-fps.md) — the same engine, the other genre - [ECS and game code](../concepts/ecs.md) - [The wasm boundary](../concepts/wasm-boundary.md) - [Characters](../concepts/characters.md) - [Recipes](../recipes/index.md) # FILE: docs/start/04-deploy.md # Ship it A built game is **static files**. There is no server, no runtime, no build step on the host. Put `dist/` behind any CDN and you are done. ## 1. Build ```sh npm run build # gameable build --release ``` Six steps, in this order: 1. `jco guest-types` writes the ambient `gameable:engine/*@0.2.0` declarations into `.gameable/guest-types`. 2. `tsc --noEmit -p tsconfig.json`. A type error fails here, not thirty seconds later inside QuickJS. 3. `jco componentize --backend qjs` turns `src/game.ts` into `.gameable/game.wasm`. Red `UNRESOLVED_IMPORT` warnings for the `gameable:engine/*` specifiers are expected noise — componentize resolves those itself, after the bundle — and `gameable build` filters them out. 4. `jco transpile --instantiation async --no-nodejs-compat` unpacks the component into `dist/guest`: nine core `.wasm` files and a `game.js` whose `instantiate` the host calls. 5. `wasm-opt -Oz` over each of those core modules, under `--release`. Binaryen cannot parse a **component**, so this happens after transpile, never before. It is skipped with a warning when `binaryen` is not installed. 6. `vite build` with `GAMEABLE_WASM=1`, which is what tells the page to load the component instead of running your TypeScript directly. The result is `dist/`, typically a couple of megabytes of wasm plus whatever splats and audio your manifest points at. ## 2. Check the budget ```sh npm run build -- --report ``` ``` report component 2.07 MiB raw component brotli 579.5 KiB (budget 819.2 KiB) dist/guest 2.53 MiB raw dist/guest brotli 605.8 KiB instantiate cold 418.2 ms instantiate warm 11.6 ms tick p50 (1000) 0.087 ms tick p99 0.402 ms (budget 1.50 ms) ``` Sizes are brotli at quality 11, which is what a CDN serves. The timings come from instantiating the real component in node and ticking it a thousand times against a `NullEngineAdapter` — the boundary, with no renderer attached. Two budgets fail the build: a 99th-percentile tick over **1.5 ms** (a quarter of a 60 Hz frame) and a component over **0.8 MB** brotli. `--no-gate` prints the numbers without failing, for when you are measuring rather than shipping. Wire `--report` into CI and a size or speed regression stops being something anyone has to notice. ## 3. Host it Upload `dist/`. Any static host works: S3 plus CloudFront, Cloudflare Pages, Netlify, GitHub Pages, nginx, `python -m http.server` for a look. ```sh npm run preview # vite preview, to check the built output locally ``` ### You do **not** need COOP/COEP Cross-origin isolation (`Cross-Origin-Opener-Policy: same-origin` plus `Cross-Origin-Embedder-Policy: require-corp`) is only needed for `SharedArrayBuffer`. Gameable Engine v1 runs the guest and Jolt on the main thread behind a `Sandbox` interface, and moves nothing across a worker boundary, so nothing asks for one. Setting the headers anyway is not harmful, but it will break embedded third-party content and it buys you nothing here. If a later version moves the simulation to a worker with transferable buffers, that is still transferable buffers — not shared memory — and the requirement does not change. ### Serve `.wasm` as `application/wasm` This is the one server setting that matters. `WebAssembly.instantiateStreaming` refuses any other MIME type, and the failure looks like a mysterious network error rather than a configuration mistake. Most hosts get it right. Check with: ```sh curl -sI https://example.com/guest/game.core.wasm | grep -i content-type # content-type: application/wasm ``` nginx: ```nginx types { application/wasm wasm; } ``` Apache: ```apacheconf AddType application/wasm .wasm ``` `_headers`, for Netlify and Cloudflare Pages: ``` /*.wasm Content-Type: application/wasm Cache-Control: public, max-age=31536000, immutable ``` ### Compression and caching Serve the `.wasm` and `.js` files brotli-compressed; that is the difference between 2.5 MB and 600 KB, and it is the number `--report` prints. Everything Vite emits with a content hash in its name can be `immutable`; `index.html` must not be. ### A subdirectory Vite needs to know: ```js // vite.config.ts export default { base: '/my-game/' }; ``` Asset ids are unaffected — they resolve through the manifest's `baseUrl`, not through the page URL. ## Verify ```sh npm run build -- --report # exits 0, both budgets green npm run preview # walk, shoot, win, in the built output curl -sI /guest/game.core.wasm | grep -i content-type ``` Then open the deployed URL in Chrome with the console open. No 404s, no `application/octet-stream`, and the game plays exactly as it did in `npm run dev` — the two modes share the same guest runtime, and a parity test proves it. ## Next - [Build the wasm guest](../recipes/build-the-wasm-guest.md) — the pipeline by hand - [Debug with doctor](../recipes/debug-with-doctor.md) - [The wasm boundary](../concepts/wasm-boundary.md) - [Troubleshooting](../troubleshooting.md) # FILE: docs/start/05-make-a-multiplayer-game.md # Make a multiplayer game From nothing to two tabs on one street, with a kit: a multiplayer game that already works, for you to change. This page walks the **hangout** kit; every other kit works the same way. ## 1. Pick a kit ```sh npm create gameable -- --list ``` prints every template, one line each, with the multiplayer kits marked: | Kit | Players | What it is | | --------- | ------- | ---------------------------------------------------------------- | | `hangout` | 1-12 | a street of six houses: chat, colours, benches, doors and cars | | `brawl` | 2-4 | punch, dash and ground slam; first to three knockouts | | `survive` | 4-6 | gather and build by day, hold the camp against creatures by night | | `mystery` | 3-6 | one hidden "it", tag-outs and votes; a round needs three players | Every kit is also hosted on the [Play](../play.md) page. **Play together** there opens a room on the hosted room server; copy the link the corner shows into a second tab and you are two players, with nothing installed. ## 2. Scaffold ```sh npm create gameable my-street -- --template hangout cd my-street npm run build:guest # Play Solo's authority runs the wasm guest npm run dev # http://localhost:5196 ``` The page plays solo: a room of one, the authority in the page. Walk with WASD, `E` opens a door or gets in a car, `F` sits on a bench, `1`-`6` change your colour, Enter chats. ## 3. Start a room server In a second terminal, in the same directory: ```sh npx gameable serve --direct # the room server, ws://localhost:8790 ``` `--direct` runs `src/game.ts` as it is, with no build. It reads the file once, so restart it after an edit. It takes pages from `http://localhost:*` and `http://127.0.0.1:*`. ## 4. Two tabs Open `http://localhost:5196/?room=new&rooms=http://localhost:8790`. Once it joins, the corner shows a four-letter code and **Copy link**. Open the link in a second tab: two residents on one street, each moving in their own tab and seen in the other. Chat in one tab, read it in both. The address picks the room: `?room=new` makes one, `?room=CODE` joins it, `?room=quick` joins any open one, and no `?room=` plays solo. `?rooms=` works only on a local page; a deployed game talks to its own site's room server. Press `3` for a colour, close the tab and open the link again: the colour comes back. The kit keeps it in your player document (`ctx.data`). Without a database (`GAMEABLE_PG_URL`), `serve` keeps documents in memory until it stops. ## 5. Change something Every number worth changing is in the `rules` block of `src/game.ts`. Set `runSpeed: 8`, restart `serve --direct`, reload both tabs, and hold Shift. Then run the kit's tests, which drive the authority headlessly with twelve players and no browser: ```sh npm test ``` ## See also - [Multiplayer](../concepts/multiplayer.md): rooms, the authority, players - [Play with friends](../recipes/play-with-friends.md): the third-person template in rooms, with one edit - [Play](../play.md): every kit, hosted - [Ship it](./04-deploy.md): the page is static files; the room server runs `gameable serve` on built games # FILE: docs/concepts/index.md # Concepts Gameable Engine splits a game in two along one line: the **host** is a browser page that owns the `WebGPURenderer`, Jolt physics, input, audio, the asset registry and the splat characters; the **guest** is all of your game logic, TypeScript compiled to a QuickJS WebAssembly component with bitecs 0.4 inside it. They meet at a single WIT world and a single export per frame — `tick(frame-input) -> frame-output` — so entity handles are minted by the guest, continuous data crosses as packed `list`, structural changes cross as a batched command list, and the only synchronous call back into the host is a physics query. The rest of the engine follows from that shape: the host loop advances simulation in whole `1/60 s` steps and renders whenever the browser asks, blending the gap with `alpha`; every host subsystem is an `EngineModule` plugged into that loop in `order`; content is addressed by string id, never by URL; worlds are gaussian splats that three.js sorts on the GPU; and a character is gaussians rewritten every frame by a compute shader, straight into the renderer's buffers. Because the guest is a value-in, value-out function, `npm run dev` can run the _same_ TypeScript directly in the host's realm with no build step, and a parity test hashes both modes to keep them honest. ## One frame, end to end ``` browser rAF │ ▼ ┌───────────────────┐ │ beginFrame() │ input module latches keys, mouse deltas, gamepads └─────────┬─────────┘ │ ▼ ╔═════════╧═════════════════════════════════════════════╗ ║ fixedUpdate(1/60), 0..5 times — the simulation step ║ ║ ║ ║ input state ──┐ ║ ║ body buffer ──┼──► frame-input ──► GUEST tick() ║ ║ events ──┘ │ bitecs ║ ║ │ systems ║ ║ ▼ ║ ║ frame-output ─┤ ║ ║ transforms │ commands ║ ║ │ ║ ║ apply commands ◄─────────────────────┘ ║ ║ spawn / add-body / play-sound / set-expression ║ ║ │ ║ ║ ▼ ║ ║ physics.step(dt) ──► new body transforms ║ ╚════════════════════╤══════════════════════════════════╝ │ (leftover time = alpha) ▼ ┌───────────────────────────────────────────────────────┐ │ update(dtReal, alpha) — presentation only │ │ interpolate transforms onto three objects │ │ animator: body / additive / face / procedural │ │ character: rig ─► decoders ─► lift ─► GPU buffers │ └────────────────────────┬──────────────────────────────┘ ▼ renderer.render(scene, camera) (sRGB splat pass first: depth, splats back-to-front) │ ▼ endFrame() ``` Everything above the `alpha` line is deterministic and replayable; everything below it is presentation and may be skipped, interpolated or degraded. ## The pages | Page | What it answers | | --------------------------------------- | ---------------------------------------------------------- | | [The engine loop](./engine-loop.md) | Fixed step, substep cap, `alpha`, interpolation | | [Engine modules](./modules.md) | How a host subsystem plugs in, `order`, services | | [Assets and the manifest](./assets.md) | `assets.json`, string ids, handles, loaders | | [The wasm boundary](./wasm-boundary.md) | The WIT world, the JS shapes, direct vs wasm mode | | [ECS and game code](./ecs.md) | `defineGame`, bitecs, tick order, zero allocation | | [Gaussian splats](./splats.md) | The four GPU buffers, sorting, slots, the fork | | [Splat characters](./characters.md) | Rig, decoders, lift, rig backends, expression spaces | | [Animation](./animation.md) | The four layers, locomotion blending, head aim | | [Physics](./physics.md) | Jolt bodies, layers and masks, `CharacterVirtual`, queries | | [Multiplayer](./multiplayer.md) | The authority, the view, roles, Play Solo, room addresses | ## Where the rules come from A concept page says how something behaves. `AGENTS.md`, at the repository root, states the rules that behaviour depends on. ## See also - [Recipes](../recipes/index.md) — one task, at most two files - [Glossary](../glossary.md) — what the jargon means here - [Troubleshooting](../troubleshooting.md) — symptom first # FILE: docs/concepts/engine-loop.md # The engine loop The host runs a single `requestAnimationFrame` loop with a fixed-step accumulator. Simulation advances in whole steps of `1/60 s`; rendering happens whenever the browser asks. The two are joined by `alpha`, the fraction of a step left in the accumulator, which is how far to blend a transform from where it was to where it is. Each frame, in order: 1. `beginFrame()` on every module that implements it — input capture. 2. `fixedUpdate(dt)` on every module, **0 to 5 times**, `dt` always `1/60`. 3. `update(dtReal, alpha)` on every module, exactly once. 4. `renderer.render(scene, camera)`, with the sRGB splat pass (`attachSrgbPass`) hooked in: the opaque depth, then the splats blended on sRGB values, laid over the frame. 5. `endFrame()` on every module that implements it. Game logic only ever sees the fixed step, so a replay of the same inputs produces the same simulation on any machine. ## Who runs when inside a fixed step Every module implementing a hook is called in `order`, lowest first: | `order` | Module | What it does in `fixedUpdate` | | ------- | --------- | ----------------------------------------------------- | | `-100` | `input` | Takes the frame's key, mouse and gamepad edges | | `-50` | `game` | One guest tick, then applies its commands | | `0` | `physics` | Steps Jolt, then fires `physics:stepped` | | `200` | rendering | Splats, overlays — `update` only, nothing in the step | **The guest runs before physics**, and that is worth a paragraph. A `move-character`, `apply-impulse` or `set-body-velocity` the guest emits is applied to the world and then simulated by the step that follows it, in the same 1/60 s. Run the guest after physics instead — as the engine used to — and every command waits for the next step: one whole step of input latency between the key going down and the character moving. The bodies the guest reads are still the previous step's, and deliberately so: they are the state it reacted to when it emitted those commands, which is what keeps a replay deterministic. They reach it by way of `physics:stepped`, which the game module subscribes to. The same handler writes each body's row straight onto the entity it drives, so a physics-driven object never sends its transform back across the wasm boundary — see [the wasm boundary](./wasm-boundary.md) and [Physics](./physics.md). ## The substep cap and the death spiral A frame that takes 200 ms owes twelve steps. Running all twelve makes the next frame slower still, which owes more — the accumulator runs away and the game freezes. So the loop runs at most `maxSubsteps` (default 5) and **throws the rest away**: time is lost, and the game stays responsive. `FrameTiming.clamped` says when that happened. The other way round, a 60 Hz display driving a 60 Hz simulation lands within a rounding error of a whole step every frame. The accumulator comparison carries a microsecond of slack so float noise cannot turn that into an alternating zero-step/two-step judder. ## Interpolation `TransformStore` keeps every entity's transform twice: as it was at the end of the previous fixed step, and as it is now. `commit()` moves current to previous; `writeInterpolated(id, object3d, alpha)` writes the blend onto a three object — lerped position and scale, slerped rotation, straight out of flat typed arrays with no temporaries. ## Driving it yourself `createFixedLoop` is pure and has no `requestAnimationFrame` in it, so it can be driven from a test, a benchmark or a replay: ```ts import { createFixedLoop } from 'gameable/core'; let ticks = 0; const loop = createFixedLoop({ fixedDt: 1 / 60, maxSubsteps: 5, fixedUpdate: () => { ticks += 1; }, update: (dtReal, alpha) => { console.log(dtReal, alpha); }, }); loop.step(0); // baseline frame: no fixed step runs const timing = loop.step(1000 / 30); // 33.3 ms buys two steps console.log(ticks, timing.substeps, timing.alpha); // 2 2 <0..1> ``` ## Rules - **Nothing in `fixedUpdate` or `update` may allocate.** They run at least 60 times a second. Preallocate typed arrays, pool objects, use dirty flags. - **`update` is for presentation**, `fixedUpdate` is for simulation. Anything that must be deterministic belongs in the fixed step. - **`time.timeScale` scales the simulation**, not the frame rate, and not the step: a fixed step is always `1 / fixedHz` long. Half speed means a frame buys half as many steps, so physics and the game see one constant `dt` at every speed and a recording replays at any speed. `0` pauses the simulation while frames keep rendering. ## See also - [Modules](./modules.md) - [The wasm boundary](./wasm-boundary.md) - [Create an engine](../recipes/create-an-engine.md) An explicit guest `markMoved(entity, TRANSFORM_FLAGS.ROTATION)` keeps the authored visual rotation through that fixed step’s physics readback. Body position still comes from physics. On a step without an authored rotation, the body rotation is used again. This lets a character face its travel direction without rotating its collision capsule. # FILE: docs/concepts/modules.md # Engine modules Every host subsystem is an `EngineModule`: ```ts import type { EngineModule, HostContext } from 'gameable/core'; /** What other modules and game code get from `engine.get('spin')`. */ interface SpinService { /** Radians turned so far. */ angle: number; } /** * A module that just turns. * * @returns The module, ready to pass to `createEngine`. */ export function spin(): EngineModule { const service: SpinService = { angle: 0 }; return { id: 'spin', order: 100, init: (ctx: HostContext) => { console.log(ctx.caps.webgpu); return service; // published as engine.get('spin') }, fixedUpdate: (dt) => { service.angle += dt; }, dispose: () => { service.angle = 0; }, }; } ``` `createEngine({ modules: [input(), physics(), audio(), splat()] })` registers them; the engine drives them. A module owns its own resources and releases them in `dispose`; the engine never reaches inside one. `init(ctx: HostContext)` gets the assets, events, clock, config, capabilities and the service registry — no scene, camera or renderer, because the same module list can boot a headless engine (`createHeadlessEngine` from `gameable/core/headless`, a multiplayer room's authority), where none of those exist. A module that draws asks for them by name: ```ts import { requireRenderContext, type EngineModule } from 'gameable/core'; export const sky: EngineModule = { id: 'sky', init: (host) => { const ctx = requireRenderContext(host, 'sky'); // throws a ModuleError when headless ctx.scene.add(ctx.camera); }, dispose: () => undefined, }; ``` ## Order Modules run sorted by `order` (default `0`), then by registration order. `init` runs in that order, `dispose` in reverse. Rough convention: | `order` | Who | | ------- | ----------------------------------------- | | `-100` | input | | `-50` | gameplay, the wasm sandbox | | `0` | physics | | `200` | rendering helpers, splats, overlays | Gameplay runs **before** physics on purpose, so that the commands a guest tick emits are simulated by the step that follows rather than the next one. See [the engine loop](./engine-loop.md#who-runs-when-inside-a-fixed-step). Because `init` is awaited one module at a time, a module may call `ctx.get('physics')` for anything registered ahead of it. ## Services and declaration merging A module publishes a service by returning it from `init`, or by calling `ctx.registerService(id, service)`. `engine.get(id)` is typed through the `EngineServices` interface, which core deliberately leaves **empty**. Each package merges its own entry in: ```ts import type { PhysicsService } from './service.js'; declare module 'gameable/core' { interface EngineServices { physics: PhysicsService; } } ``` Importing the package is then enough for `engine.get('physics')` to be typed — no cast at the call site, and core never has to know the package exists. The same pattern extends `EngineEventMap` for typed events. ## Per-frame hooks All optional; a module is only called for the hooks it implements. | Hook | When | | ------------------- | --------------------------------------- | | `beginFrame()` | Before the frame's fixed steps | | `fixedUpdate(dt)` | Once per fixed step, `dt` always `1/60` | | `update(dt, alpha)` | Once per frame, before rendering | | `endFrame()` | After rendering | None of them may allocate. ## Features: what a game declares A game says which optional modules it needs in its definition: `defineGame({ features: { characters: true } })`. `featuresOf(definition)` turns that into a table of names and options, and `resolveFeatures` looks each name up in the host's table of loaders, then `bindFeatures` runs the ones that need the booted engine. A feature is an explicit factory behind a dynamic import, never a side-effect import, so a feature nobody declared is never downloaded. There is one table per side. The page's is `clientFeatures()` from `gameable/host/features`; a server table comes with multiplayer. The page imports the game definition in both modes, direct and wasm, only to read its `features`. See [Turn on a feature](../recipes/turn-on-a-feature.md). ## See also - [The engine loop](./engine-loop.md) - [Assets and the manifest](./assets.md) - [Create an engine](../recipes/create-an-engine.md) - `packages/core/README.md` — gameable/core # FILE: docs/concepts/assets.md # Assets and the manifest All content is declared in an `assets.json` manifest of `{ id, type: splat | gltf | character | audio, src, tags?, collider?, rig? }` entries, validated against a [JSON Schema](../schemas/assets.schema.json). **String ids are the guest/host contract**: game logic asks for `'arena'`, never for a URL or a path. The registry is the only thing that knows where bytes live, so content can move between a static directory, an npm placeholder package and the AvatarOS Asset Manager without touching game code. ```json { "version": 1, "baseUrl": "/assets/", "assets": [ { "id": "arena", "type": "splat", "src": "arena.spz", "tags": ["world"] }, { "id": "shot", "type": "audio", "src": "sfx/shot.ogg", "tags": ["sfx"] } ] } ``` ## The registry ```ts import { createAssetRegistry, createDefaultLoaders, parseManifest } from 'gameable/assets'; const manifest = parseManifest({ version: 1, baseUrl: '/assets/', assets: [{ id: 'shot', type: 'audio', src: 'sfx/shot.ogg', tags: ['sfx'] }], }); const assets = createAssetRegistry({ manifest, loaders: createDefaultLoaders().loaders }); assets.onProgress((p) => { console.log(`${String(p.loaded)}/${String(p.total)} ${p.id}`); }); await assets.preload('sfx'); const bytes = assets.get('shot') as ArrayBuffer; console.log(assets.resolve('shot'), bytes.byteLength); // 1 ``` `createEngine({ manifest })` builds one of these for you and puts it on `engine.assets` and `ctx.assets`. ## Handles `resolve(id)` returns a stable, 1-based `u32` assigned in manifest order; `0` means "no asset". That number is what crosses the wasm boundary, because a `u32` costs nothing to pass and a string costs a copy. `idOf(handle)` goes back the other way, and `entry`, `url`, `load` and `get` all accept either form. ## Loaders A loader is `(url, entry, ctx) => Promise`, registered per asset type. Core registers two: - `gltf` — three's `GLTFLoader` with a `DRACOLoader` pointed at a configurable decoder directory (`dracoDecoderPath`, default `/draco/`), and a `KTX2Loader` when `ktx2TranscoderPath` is given. Both are constructed lazily, on the first glTF that needs them. - `audio` — `fetch` to an `ArrayBuffer`. Decoding needs an `AudioContext`, so it is `gameable/audio`'s job, not the registry's. `splat` and `character` are registered later by the packages that own them: ```ts import type { AssetRegistry } from 'gameable/assets'; /** * Teach a registry how to load splats. * * @param assets The engine's registry. */ export function registerSplatLoader(assets: AssetRegistry): void { assets.registerLoader('splat', async (url) => { const response = await fetch(url); return response.arrayBuffer(); }); } ``` Asking for an asset whose type has no loader fails with a message naming `registerLoader` and the type, rather than a silent `undefined`. ## Validation `parseManifest(json)` is hand-written — no JSON Schema validator ships to the browser — and throws a `ManifestError` carrying the exact path (`assets[2].collider.radius`) and what is wrong with it. It enforces everything the schema does, including the conditional rules: `character` entries need a `rig`, a `gnm` rig needs a `pack`, a `box` collider needs `halfExtents`, ids are unique and match `^[a-z0-9][a-z0-9._-]*$`. `resolveAssetUrl(manifest, entry)` joins `baseUrl` and `src`. Absolute URLs, protocol-relative URLs and root-relative paths are passed through untouched; when `baseUrl` carries a scheme, resolution goes through `URL`, so `../` collapses properly. ## See also - [assets.schema.json](../schemas/assets.schema.json) - [Engine modules](./modules.md) - `packages/assets/README.md` — gameable/assets - `packages/assets-placeholder/README.md` — gameable/placeholder ## Asset Manager adapter `gameable/aam` is the one supported way content reaches a game without being in its `assets.json`. It is optional: with `VITE_ASSET_MANAGER_URL` unset, `aamConfigFromEnv()` returns `null` and nothing about the boot path changes. What it does **not** do is as important as what it does. It is not a second addressing scheme — it produces ordinary `AssetEntry` values, merged into the manifest before the registry is built, so game logic still asks for `'char.myra'` and the registry is still the only thing that knows a URL. ```ts import { loadManifest } from 'gameable/assets'; import { aamConfigFromEnv, buildCharacterManifestEntries, createAamClient, createAamResolver, mergeManifests, } from 'gameable/aam'; let manifest = await loadManifest('/assets/assets.json'); const config = aamConfigFromEnv(); if (config !== null) { const client = createAamClient({ ...config, cache: 'cache-storage' }); const built = await buildCharacterManifestEntries(client, 'myra'); manifest = mergeManifests(manifest, built.entries); console.log(built.characterId); // 'char.myra' } ``` A character becomes one `character` entry whose `src` is a **virtual directory URL** — `/api/characters//ogs/` — that the character loader appends filenames to, exactly as it would to a static directory, plus one `gltf` entry per body clip. Face clips come back as a separate JSON list rather than as entries, because ARKit weight tracks have no manifest type and inventing one would be a schema change. Ids are derived from the server's clip names, sanitised to `^[a-z0-9][a-z0-9._-]*$`; a collision throws, because two clips quietly collapsing into one id surfaces as a missing animation three scenes later. ### The key The API key is read once from `VITE_ASSET_MANAGER_API_KEY`, lives only inside a client closure, and is never logged or placed in a URL. `createAamResolver` returns a `fetch` that attaches it **only** to URLs under `baseUrl`: ```ts const resolver = createAamResolver(client); const registryFetch = resolver.fetch; // for loadManifest / loadCharacterBundle ``` That restriction is the point. A single wrapper that signed every request would post the credential to whatever public CDN happened to appear in a manifest. An empty key is legitimate — a deployment on the same site authenticates with its session cookie, and no `X-API-Key` header is sent at all. Transient failures are retried with exponential backoff (a network error or a 5xx, three retries by default); a 4xx is definitive and returns immediately, because retrying a 401 only delays the real message. `cache: 'cache-storage'` adds a persistent byte cache for the large bundle files, guarded by `typeof caches !== 'undefined'` so it is inert under Node. ## Placeholder pack `gameable/placeholder` is the other end of the same idea: content that is already in the manifest before a game has any of its own. It ships an arena splat, its collider, four sound effects and a facial idle clip — about 1 MB, against the 12 MB cap in `AGENTS.md` rule 14. ```ts import { parseManifest } from 'gameable/assets'; import { PLACEHOLDER_ASSETS_BASE, placeholderManifest } from 'gameable/placeholder'; const manifest = parseManifest(placeholderManifest, { baseUrl: PLACEHOLDER_ASSETS_BASE }); console.log(manifest.assets.map((a) => a.id)); // ['env.arena', 'sfx.shot', 'sfx.hit', 'sfx.pickup', 'sfx.step'] ``` That works under Node and under a dev server, and **not** in a bundled build: `PLACEHOLDER_ASSETS_BASE` is `new URL('../assets/', import.meta.url)`, and in a production bundle `import.meta.url` is the emitted chunk, not the package. A browser build has to hand the bundler each file so it is copied and hashed, which is what `templates/fps/src/main.ts` does: ```ts import arenaUrl from 'gameable/assets/arena.spz?url'; const manifest = parseManifest({ version: 1, baseUrl: '/', assets: [{ id: 'env.arena', type: 'splat', src: arenaUrl }], }); ``` The template keeps the ids in `src/assets.json` and writes the placeholder sources as `@placeholder/`, which `main.ts` maps onto those `?url` imports. A game's own files go in `public/` and need no import at all. Every byte is **generated**, not collected: `scripts/gen-arena.mjs` scatters gaussians over analytic planes, boxes, a wedge and a dome and writes SPZ v2; `gen-sfx.mjs` synthesises the WAVs from oscillators and seeded noise; `gen-face.mjs` evaluates hand-authored ARKit curves at 30 fps. The generators are seeded, so `npm run generate -w packages/assets-placeholder` reproduces the committed files byte for byte, and `prebuild` runs it so a build cannot ship stale bytes. That is what makes the licensing trivial — there is no upstream, so `assets/CREDITS.md` has nothing to attribute. ### What the manifest cannot hold Two of the packaged files are not manifest entries, and deliberately so. The schema fixes `type` to `splat | gltf | character | audio`; an ARKit weight track and a list of spawn coordinates are neither, and adding a type to hold them would be a schema change made for a placeholder. They ship as ordinary exports instead — `arenaSpawns`, and `placeholderAssetUrl('face_idle.arkit.json')` — which is the same call the Asset Manager adapter makes when it returns face clips outside the manifest. ### The collider format `env.arena` declares `{ shape: 'mesh', src: 'arena.collider.bin' }`. That file is the smallest thing that can carry a triangle mesh: little-endian `u32` `vertexCount`, `u32` `indexCount`, `f32 positions[3n]`, `u32 indices[m]`, no magic number and no padding. `parseCollider` and `encodeCollider` in the package read and write it, and Jolt wants exactly those two flat arrays — a glTF collider would drag a glTF parser into the physics path for eight boxes and a wedge. # 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: docs/concepts/splats.md # Gaussian splats Worlds are gaussian splats rendered by three.js r186's native `GaussianSplat` on a `WebGPURenderer`. There are two paths and they exist for different reasons. **Static** is for worlds. `loadSplat` decodes SPZ (recommended), PLY, SPLAT or KSPLAT — or a glTF carrying `KHR_gaussian_splatting` — and `createSplatObject` puts an unmodified `GaussianSplat` in the scene. Nothing is forked: a world never changes, so three's one-time repack and its camera-driven re-sort are exactly right. **Dynamic** is for characters. `createAnimatedSplat` allocates a fixed capacity of gaussians with **no CPU copy at all** and hands out the four `GPUBuffer`s behind them, so a compute shader writes centres, covariances and colours straight into the buffers the vertex stage reads. That needs a maintained fork of `GaussianSplat.js`: a subclass cannot do it, because the base constructor repacks the source geometry, keeps it, and derives bounds from it before `super()` returns, and undoing that afterwards costs 13 MB and about 90 ms of CPU per 250k-gaussian character. ## What a splat is, on the GPU `GaussianSplat` repacks its source geometry **once** into four storage buffers and never looks at the source attributes again except for raycasting, bounds and the WebGL CPU sort. Those four buffers are the real data model, and a producer must match them exactly: | Buffer | WGSL type | Contents | Bytes/splat | | ------------- | ------------------ | ----------------------------------------- | ----------- | | `center` | `array>` | `xyz` = centre in local space, `w` unused | 16 | | `covarianceA` | `array>` | `(c00, c01, c02, c11)` | 16 | | `covarianceB` | `array>` | `(c12, c22, 0, 0)` | 16 | | `color` | `array` | `pack4x8unorm(vec4(r, g, b, a))` | 4 | The six covariance floats are the upper triangle of the symmetric 3×3 covariance `Σ = (R·S)(R·S)ᵀ`, in the order `c00, c01, c02, c11, c12, c22` — the order three's own `writeCovariance` writes, split 4/2 across the two `vec4` buffers. `covarianceB.zw` is padding and is never read. Two consequences worth internalising: - **52 bytes per gaussian.** A 250 000-gaussian character is 13 MB of GPU memory, fixed at allocation and never reallocated. - **A colour word of zero is invisible.** Alpha 0 falls out of `pack4x8unorm` for a zeroed word, so a slot nobody has written renders as nothing. That is what makes unallocated capacity free, and what `clearSlots` relies on when a branch is retired. All of this lives behind `packages/splat/src/backendBuffers.ts`, which is the only file in the repository allowed to touch `renderer.backend` (`AGENTS.md` hard rule 8). If a three upgrade moves the private surface, exactly one file fails, and it fails with a message naming the version it was written against. ## Colour space Splats are nearly always sRGB, fitted blending sRGB values, so both paths default to `colorSpace: 'srgb'`, drawn only by the sRGB pass (`attachSrgbPass`, which the engine attaches); without it they draw nothing and warn once. `'linear'` draws in the app's own pass. ## Sorting Splats are transparent and must be drawn back to front, so every frame's draw order is a depth sort of every gaussian. On WebGPU that is `CountingSort`: 4096 depth bins, four compute passes, and — this is the surprising part — a cost that is almost flat in the splat count. Three decides _whether_ to sort by comparing the view direction against the last one it sorted for: ``` needsSort = dot(sortDirection, lastSortDirection) < 0.9995 // about 1.81 degrees ``` So a camera that barely moves does not re-sort, and a **static** splat is usually free. A camera panning at 2°/frame re-sorts every frame, which is the worst case and the one budgeted below. That threshold is also a trap for dynamic splats: if a producer moves the gaussians while the camera holds still, the order is stale and three has no way to know. `markGaussiansChanged()` is the fix — it clears the one internal flag that forces a dispatch — and a producer calls it once per frame after its compute pass. Rendering without it looks _almost_ right, which is why the end-to-end suite asserts `sortsPerFrame === 1` rather than trusting review. ### Measured cost Reference box, headless Chromium with WebGPU, 1280×720, camera turning 2°/frame so **every frame re-sorts**. `sort` is GPU time for the four `CountingSort` passes; `render` is GPU time for the draw; `writeBuffer/frame` counts every CPU→GPU upload the whole page makes. | Mode | Splats | Backend | Sort ms (p50) | Render ms (p50) | Producer ms (p50) | writeBuffer/frame | | ------- | -----: | ------- | ------------: | --------------: | ----------------: | ----------------: | | static | 150 k | WebGPU | 0.31 | 0.13 | — | 2 (160 B) | | dynamic | 100 k | WebGPU | 0.31 | 0.09 | 0.004 | 2 (160 B) | | dynamic | 250 k | WebGPU | 0.33 | 0.21 | 0.008 | 2 (160 B) | | dynamic | 500 k | WebGPU | 0.37 | 0.42 | 0.013 | 2 (160 B) | Read that column again: **the sort is 0.31 ms at 100 k and 0.37 ms at 500 k.** The bin count dominates, not the splat count, so sorting is a fixed ~0.35 ms tax rather than a per-splat cost. The spike measured 0.42 ms for a 1 M-gaussian SPZ on the same box. What does scale is the _draw_, roughly linearly in splats. The two `writeBuffer` calls per frame are three's own camera uniforms; they are present in the static case too. **No gaussian data crosses the bus after allocation** — that is the whole point of the dynamic path, and it is asserted, not assumed. ## Dynamic splats: slots One character is one `AnimatedSplat`, and every branch of it — head, body, eyes, hair — is a slot range inside that one buffer. That is not an optimisation: splats can only be sorted against others in the same object, so an avatar split across several `GaussianSplat`s would have its own parts drawn in the wrong order against each other. ```ts const sink = await createAnimatedSplat(renderer, { capacity: 250_000, boundingSphere: { center: [0, 1, 0], radius: 1.4 }, }); scene.add(sink.object3D); const head = sink.allocate(120_000); // { offset: 0, count: 120000 } const body = sink.allocate(90_000); // { offset: 120000, count: 90000 } ``` Allocation is first fit, and frees coalesce with both neighbours, so a load/unload cycle does not fragment the buffer. Capacity is fixed for the object's lifetime: there is no growth path, because growing would mean reallocating four GPU buffers and rebuilding every bind group that points at them. The bounding sphere is **not** cosmetic. The sort's depth range is derived from it, and a sphere that does not contain the gaussians crushes them into too few of the 4096 bins. Frustum culling is off in dynamic mode for the same reason it has to be declared rather than measured: a producer can move gaussians outside it between two frames and the CPU would never know. Keep it up to date with `setBoundingSphere`. ## Draw order Splats draw **after** opaque geometry, with depth test on and depth write off, and after ordinary transparent meshes (`renderOrder = 1000`, `SPLAT_RENDER_ORDER`). An sRGB splat (the default) is drawn by the sRGB pass (see Colour space): before your render, against a depth-only render of your opaque objects, into a target of its own; during your render one transparent object, placed at the nearest splat with its render order, lays the result over the frame. A linear splat draws in your render directly, as a transparent object. This is a real constraint on level design, not a detail: - **A transparent mesh must not intersect a splat volume.** Three sorts transparent objects by their object centre, so an intersecting pair has exactly one draw order for the whole overlap and one of them will be wrong somewhere. The sRGB splats are one such object between them: a pane of glass in front of a character is drawn under it. - **Two splat objects that overlap in space will interleave incorrectly**, for the same reason. Each sorts its own gaussians perfectly; nothing sorts across objects. If two things must blend into each other, they belong in one `AnimatedSplat`, in different slot ranges. - Opaque geometry is fine at any depth: its depth is drawn first, in one render, and the splats test against it. Each mesh keeps its material's side, and a cut-out (`map` or `alphaMap` with `alphaTest`) hides only where its texture is solid. - The sRGB pass uses camera layers 30 and 31 for its own renders: leave them free. - The pass hooks `scene.onBeforeRender` and `scene.onAfterRender`. If your app assigns those itself, or renders through something that skips them, attach it with `attachSrgbPass(renderer, scene, { hook: false })` and call `pass.begin(camera)` just before `renderer.render(scene, camera)` and `pass.end()` just after. Without the hook, a render of the scene made during yours (a reflection) is not told apart and shows the splats too. ## The WebGL fallback `WebGPURenderer` falls back to a WebGL backend when the browser has no WebGPU, and static splats still work there — three sorts on the CPU instead. It is slower, and it gets worse with size, because unlike the GPU sort this one really is linear: | Splats | CPU sort ms (p50) | | -----: | ----------------: | | 75 k | 0.9 | | 150 k | 1.7 | | 250 k | 3.0 | | 1 M | 11.6 | At 1 M gaussians the sort alone is two thirds of a 16.6 ms frame. Budget the fallback at a few hundred thousand splats, and remember the sort only runs when the camera turns far enough — a player standing still pays nothing. **Dynamic splats run on the fallback, written from TSL.** There are no `GPUBuffer`s there, so a producer writes `sink.nodes` with a TSL compute node dispatched over `sink.storageCapacity` (three runs it as transform feedback: one full dispatch per frame, invocation `i` writes slot `i`). The CPU sort needs centres, which dynamic mode keeps only on the GPU, so `AnimatedSplat` reads them back behind a fence, at most every 50 ms, and sorts from them when they land; the camera side of the sort is always current. On an RTX 4080 SUPER at 250 k gaussians the read costs about 2 ms of main thread and the sort about 1.2 ms, and neither ever waits on the GPU. **GNM heads and decoder characters need WebGPU**: check `engine.caps.characters` before creating one, and design a game that degrades (capsules, not a black screen). The Gameable studio's exported characters draw on the fallback too, through the character bridge, slower; `gameable/three` too. ## The fork `packages/splat/src/three-fork/AnimatedGaussianSplat.js` is three's `GaussianSplat.js` verbatim plus marked insertions (listed in `UPSTREAM.md`): among them a capacity constructor that skips the O(N) repack, `markGaussiansChanged`, owner-supplied bounds, no-op CPU-mirror readers, the WebGL fallback's readback sort and transform-feedback-friendly storage, the colour's space, the studio's kernel and the sRGB pass's output switch. Every deviation lives between `// GAMEABLE EDIT BEGIN` and `END`, and `packages/splat/scripts/diff-upstream.mjs` strips those blocks and asserts what is left is byte-identical to the pinned upstream file. It runs as the package's `pretest` and as a unit test, so a three upgrade cannot slip through quietly. `src/three-fork/UPSTREAM.md` explains each edit, and each of the three planned edits that turned out to be unnecessary. ## See also - [Characters](./characters.md) - [Assets and the manifest](./assets.md) - [Load a splat environment](../recipes/load-a-splat-environment.md) - `packages/splat/README.md` — gameable/splat # FILE: docs/concepts/animation.md # Animation A character is animated by one object. `createAnimator({ root, expressionSpace })` binds a skinned rig root to four layers, and `update(dt)` evaluates all four and refreshes four output buffers in place. Nothing in the per-frame path allocates. ```ts import { createAnimator, validateCharacterState } from 'gameable/animation'; const animator = createAnimator({ root: rigRoot, expressionSpace: { kind: 'arkit52', dim: 52 }, locomotion: locomotionIndex, }); animator.addClip('idle', idleClip); animator.addClip('walk', walkClip); animator.setState(validateCharacterState(frame.character)); animator.update(dt, { camera }); ``` ## The four layers **Body base.** An `AnimationMixer`. Its weights come from one of three sources, checked in this order: 1. a blueprint graph, if `setGraph` was called — `playClip`, `blend`, `select` and a rule-guarded state machine, evaluated into clip weights; 2. `state.clips`, when the guest drives clips explicitly by name and weight; 3. otherwise the locomotion blend, from `state.velocity`. **Additive / gesture.** One slot. A gesture is a clip registered with `{ additive: true }` and played through `animator.gesture.play(name, { durationMs })`, which runs a fade-in, hold, fade-out envelope over it. It is applied after the base layer's weight pass and before the mixer advances, so the base layer cannot overwrite the envelope. **Face.** Face clips are per-frame ARKit-52 blendshape weights, blended by drive weight and interpolated across the loop boundary. The result is mapped into the bundle's expression space and published as `animator.expression`. A guest that computes its own expression vector can supply one on `state.expression` instead. **Procedural.** Blink overlays the ARKit blink channels. Head aim turns the neck and head toward `state.lookAt` — or the camera — and publishes the rotations as `animator.jointOverrides`. Whatever the neck could not reach is handed to the eyes as `animator.gaze`, `[pitchL, yawL, pitchR, yawR]` in radians. ## The locomotion blend Locomotion clips are tagged with the ground speed they were authored at. The sample character's clips travel inside its GLB, each glTF animation carrying `extras.aos = { speed, loop, locomotion }`, which `GLTFLoader` copies into `clip.userData`; the character bridge builds the `LocomotionIndex` from the clips tagged `locomotion` and registers every clip by name. Clips retargeted by `packages/animation/tools/retarget_locomotion.mjs` arrive as one GLB each plus a `locomotion.json` with the same fields, and `createAnimator` takes that index directly. At runtime the character's PLANAR speed — Y is excluded, because a falling character is not sprinting — picks the two clips bracketing it and crossfades them. Outside the bracketed range the blend clamps to the nearest clip and scales its playback instead: a 4 m/s run clip played at 6 m/s runs at 1.5x, so the stride keeps up with the ground and the feet do not skate. A character the Gameable studio exports (version 2) brings its clips in `clips.json`, on its own skeleton; `somaAnimationClip` turns each into an `AnimationClip`. A looping clip gets a closing key equal to its first frame at `frames / fps`, so its wrap skips no frame; one whose root travels (more than 5 cm) keeps moving at its last velocity instead. Its face `map` is the package's fitted ARKit table (`createFittedArkitMap`) when it has one. A version 1 package's clips (in `rig.glb`) have no closing key: their loops still skip a frame. ## Expression spaces Face clips are always ARKit-52. A bundle whose head is a GNM model wants 383 coefficients, or the reduced 68. That mapping belongs to the bundle, not to the engine — two characters in one scene can be in different spaces — so `expressionSpace` carries it: ```ts { kind: 'gnm', dim: 383, map: (arkit, out) => { /* bundle-supplied */ } } ``` `createAnimator` throws if a non-ARKit space arrives without a `map`. The procedural blink composes in ARKit space, before the map runs. ## Head aim clamps against the body The head look-at clamps yaw relative to the body's current forward, not to world +Z. With a world-anchored clamp, locomotion — which yaws the whole character to face the walk direction — lets the head reach body-yaw plus the neck limit, which is a neck that rotates a great deal further than a neck can. Clamping relative to the torso keeps the head within its limit whichever way the body faces. The body's forward is `root.rotation.y` unless the update context passes `bodyYaw`, which a rig parented under an entity group must do: the group carries the facing and the rig root draws, so rotating it again would double-rotate. The aim is split across `neck_01`, `neck_02` and `head` by default, or across whatever `headAim.joints` names — `c_neck` and `c_head` for the aosrig_v0 skeleton, whose root bone is `root` rather than `pelvis` (`bones: { root, head }`). Each override is a parent-local delta that pre-multiplies the bone's animated rotation. `animator.bodyPose` already has them folded in; `animator.jointOverrides` is for rig backends that take joint overrides as their own input. Use one or the other. ## Additive clips are baked, not layered at runtime Unreal computes an additive delta when the animation asset is built, not at the ApplyAdditive graph node. `bakeAdditive` reproduces that: it takes the additive type, base pose type and reference frame index that the FBX to glTF conversion strips, and rewrites the clip's keyframes once at load time. Nothing about additive layering runs per frame. ## See also - [Characters](./characters.md) - [Play an animation](../recipes/play-an-animation.md) - `packages/animation/README.md` — gameable/animation # FILE: docs/concepts/physics.md # Physics Physics is Jolt (`jolt-physics` 1.1, wasm) behind an `EngineModule`. It provides rigid bodies, `CharacterVirtual` controllers, raycast / raycast-batch / overlap-sphere queries, contact events and a wireframe debug view. Queries are the only synchronous imports the guest gets; everything that mutates the world is a command in `frame-output` (`add-body`, `apply-impulse`, `move-character`, ...). Physics steps inside `fixedUpdate`, so results are deterministic for a given command stream. ## The shape of a frame ``` fixedUpdate(dt) game (-50) -> guest tick() game: reads last step's bodies -> apply commands host: move-character, apply-impulse, ... physics (0) -> world.step(dt) host: simulates those commands now -> 'physics:stepped' host: readBodies(buffer) once, and the rows go straight onto the entities they drive -> drainContacts(pool) host: only bodies flagged report-contacts ``` The guest runs **before** the step, so a command it emits is simulated in the same fixed step — that is one step of input latency removed. The rows it reads are the previous step's: exactly the state it was reacting to. The host applies those same rows to the scene itself, which is why a body-driven entity never appears in `frame-output.transforms`; see [the wasm boundary](./wasm-boundary.md). The body buffer is a packed `Float32Array`, stride 15, one row per **enabled non-static** body, sorted ascending by body id: | lane | meaning | | ----- | ------------------------------------------------------------------------------ | | 0 | body id | | 1–3 | position x, y, z | | 4–7 | rotation x, y, z, w | | 8–10 | linear velocity | | 11–13 | angular velocity | | 14 | ground state: 0 unknown/non-character, 1 ground, 2 steep, 3 unsupported, 4 air | Static bodies are never written — they never move, and the guest already knows where it put them. Neither is a **sleeping** body re-read: Jolt parks a body that has settled, and the world replays the last row it read for it, which is one wasm crossing instead of fifteen. Any setter (`set-body-transform`, `set-body-velocity`, `apply-impulse`, `set-body-enabled`) drops that mirror, so a teleported sleeper still reads true. The buffer is preallocated and reused; `readBodies` returns the row count it _wanted_, so a guest that outgrows its buffer grows it once and carries on rather than allocating every frame. `movingBodyCount` is that number _before_ the call, which is how the host sizes its buffer without ever reading twice. ## Contacts are opt-in A body reports nothing unless it asks to. Two flags, both off by default: | Flag | Emits | | ---------------- | -------------------------------------------------------- | | `reportContacts` | `begin` when a pair starts touching, `end` when it stops | | `reportStay` | ...and `stay`, every step, for as long as it touches | One flagged side of a pair is enough — a projectile that wants to know what it hit does not need every wall in the level to agree. The default is off because contacts are not free: a world of resting crates generates a manifold per touching pair per step whether or not anybody reads it, and turning one into a record costs wasm crossings. Flag the handful of bodies whose collisions the game reacts to and the rest cost a single integer comparison. `stay` is separately opt-in because it is the expensive one: a box on a floor emits it sixty times a second forever, and `begin`/`end` are enough to answer "am I touching this". A step that produces more reportable contacts than `maxContactsPerStep` (256 by default) drops the surplus and warns once, because growing the pool would mean allocating inside Jolt's own `Step()`. ## Layers and masks A body declares what it **is** (`layer`) and what it **collides with** (`mask`), both as bitsets. Two bodies interact only when `a.mask & b.layer` and `b.mask & a.layer` are both non-zero, which makes one-way relationships impossible by construction — a pickup that ignores the player is a pickup the player also walks through. Jolt itself wants a symmetric table of _object layers_, so the module mints one object layer per distinct `(layer, mask, moving)` triple a game actually uses, lazily, and enables the pairs that the masks imply. Sixty-four slots — thirty-two static, thirty-two moving — cover any reasonable game; `layers.maxObjectLayers` raises the ceiling. ## Characters A `character` body is a Jolt `CharacterVirtual`, not a rigid body: it has no inertia, does not bounce, and is moved by setting a desired velocity rather than by forces. It carries an _inner_ kinematic body so that other bodies collide with it and raycasts hit it, which is why one body id addresses both halves. `move-character` takes the horizontal components literally. A positive `y` while grounded is a jump — an edge, consumed once, so one jump command does not re-launch the character every time it lands. After that gravity integrates until it lands. The controller sticks to the floor over small drops, walks up stairs, and refuses slopes steeper than 45° (Jolt's own default is 50°, which lets a player walk up scenery an artist drew as a wall). Only contacts below the capsule's lower hemisphere count as ground, so brushing a pillar is not standing on it. Disabling a character is the one thing `set-body-enabled` cannot do properly: the controller owns its inner body and destroys it itself, so the engine stops stepping and reading a disabled character but leaves that body in the broad phase. Things still bump into it. Remove it when it has to stop existing. ## Queries `raycast`, `raycast-batch` and `overlap-sphere` are the only blocking calls the guest may make during `tick`, because each one is a full canonical-ABI round trip. Prefer the batch form: it packs N rays into one buffer (stride 7) and returns N results (stride 9) for a single crossing. Mesh colliders respect triangle winding — Jolt ignores back faces — so a collider baked with the wrong winding is invisible to hitscan weapons while still stopping the player. ## Debug view `engine.get('physics').debugWireframe(scene)` adds a `LineSegments` drawing each body's oriented bounds. The line buffer is rebuilt only when the set of bodies changes; every other frame just rewrites the existing positions. ## See also - [The engine loop](./engine-loop.md) - [Modules](./modules.md) - [Add a physics body](../recipes/add-a-physics-body.md) - `packages/physics-jolt/README.md` — gameable/physics # FILE: packages/animation/README.md # gameable/animation ## What The animation stack, as four layers evaluated in order: a body base layer (an `AnimationMixer` driven by a blueprint graph, by explicit clip weights, or by a speed-matched locomotion blend), an additive/gesture layer, a face layer in ARKit-52 or the bundle's expression space, and procedural blink, head aim and gaze. Plus the pure clip utilities the offline tools share: track pruning, Unreal-parity additive baking, and locomotion retargeting. `update(dt)` allocates nothing. Every buffer, map and scratch quaternion is created once in `createAnimator`, the graph's indices are memoised on the graph's identity, and the head aim parks itself when there is nothing to look at. Time comes from `dt`, not from the wall clock. ## When to use You are animating a character or a skinned placeholder, or retargeting locomotion clips onto the canonical armature. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { createAnimator, validateCharacterState } from 'gameable/animation'; const animator = createAnimator({ root: rigRoot, // the skinned rig root expressionSpace: { kind: 'arkit52', dim: 52 }, locomotion: locomotionIndex, // parsed locomotion.json }); animator.addClip('idle', idleClip); animator.addClip('walk', walkClip); animator.addFaceClip('smile', smileClipJson); // Each frame, from the guest's frame output: animator.setState(validateCharacterState(frame.character)); animator.update(dt, { camera }); character.setBodyPose(animator.bodyPose); character.setExpression(animator.expression); character.setJointOverrides(animator.jointOverrides); ``` ## API - `createAnimator({ root, clock?, expressionSpace, locomotion?, headAim?, blink?, random? })` → `Animator`. - `Animator`: `addClip(name, clip, { additive?, loop? })`, `addFaceClip(name, { fps, frames })`, `setGraph(graph)`, `setLocomotion(index)`, `setState(state)`, `update(dt, { camera?, lookAt? })`, `dispose()`. - Outputs, all reused in place: `bodyPose.bones` (per-bone `xyzw` in skeleton order), `bodyPose.rootPos`, `expression`, `jointOverrides`, `gaze` (`[pitchL, yawL, pitchR, yawR]`), `boneNames`. - Sub-runtimes, usable on their own: `createGestureChannel`, `createFaceClipPlayer`, `createBlinkRuntime`, `createMovementComponent`. - State: `CharacterState { clips?, expression?, lookAt?, velocity, grounded }`, `validateCharacterState`, `planarSpeed`. - Locomotion: `LocomotionIndex`, `sortLocomotionClips`, `blendLocomotion`, `writeLocomotionWeights`, `timeScaleFor`, `validateLocomotionIndex`. - Graph: `evaluateBody`, `createGraphRuntime`, `applyWeights`, `evaluateRule`, `createSMHandler`. - Clips: `pruneBodyClipTracks`, `bakeAdditive`, `buildRestPose`, `buildBoneParents`, `buildBodyMotionClip`, `computeRootDelta`. - Retargeting: `mapTrackName`, `rebindQuaternionTrack`, `scalePositionTrack`, `hipHeightRatio`, `boneMapFromJson`; the offline driver is `tools/retarget_locomotion.mjs`. Offline locomotion helpers are independent of the generation service: `selectGaitCycle` searches an explicit frame window for a low-error loop and matches duration to target speed and hip scale. `rebindQuaternionTrack` transforms local rotations through source/target rest frames. `closeQuaternionLoop` and `closePositionLoop` distribute seam corrections over a bounded tail. `stationaryRootTrack` removes horizontal root travel, corrects FK foot heights to a contact plane, and optionally preserves source running flight. Inputs are validated; use these at asset preparation time, never inside a frame loop. Keep prompts, bone maps, take selection, checksums and art-direction adjustments in the consuming project. These helpers do not contact Kimodo or write asset files. ## Gotchas - Face clips are ARKit-52 weights; the bundle's expression space may be GNM. The map is a bundle field, not a code constant, so `expressionSpace.kind` other than `arkit52` must carry a `map(arkit, out)` — `createAnimator` throws otherwise. - `state.expression` is read as ARKit-52 when it is 52 long and as the bundle's own space otherwise. Only the ARKit path composes with the procedural blink; a vector already in bundle space bypasses it. - `bodyPose` is the FINAL pose and already has the procedural joint overrides folded in. `jointOverrides` exposes the same rotations separately, for rig backends that take them as their own input — apply one or the other, never both. - `jointOverrides` entries are PARENT-LOCAL deltas that pre-multiply the bone's animated rotation, not absolute orientations. - Head aim clamps yaw relative to the BODY, not to world +Z. Clamping against world forward lets a walking character's head reach body-yaw plus the neck limit, which is the "exorcist twist" the POC shipped with for a while. - **A graph handed to `setGraph` is immutable.** Its node and edge indices, the narrowed inner lists of every state, and each transition edge's rule graph are memoised on the identity of the authored objects, which is what makes the frame path allocation-free. Editing a node array in place is not re-read. Build a new graph object (fresh arrays — what parsing the JSON again gives you) and call `setGraph` with it; that is free, and the caches are `WeakMap`s so the old graph's entries go with it. - The gesture layer is one slot. Registering a gesture clip with `{ additive: true }` keeps the base layer's weight pass from stopping it; a gesture clip registered without it will be zeroed every frame. - The gesture channel writes an action's blend mode, loop style and clamp flag ONCE, when the gesture reaches it, and puts the blend mode back when the envelope ends. Only the weight is written per frame — `setLoop` on a running action resets its loop counter. - `bones.root` must name a bone this rig actually has. A rig with no bone by that name logs one warning at construction and leaves `bodyPose.rootPos` at zero; it does not quietly report bone 0's position instead. - `gesture.setExclusiveOverride(true)` while a full-body override owns the skeleton. Starting a gesture underneath one leaves it primed to pop back in for the tail of its fade-out when the override releases. - Every runtime takes an injected `now()`. Do not mix it with `Date.now()`: envelopes started against one clock and expired against another release early. - The animator's default clock is its OWN accumulated `dt`, not `performance.now()`: gestures, face clips and blink stop when the engine is paused, slow down with `timeScale`, and replay identically under a fixed `dt`. Pass `clock: defaultClock` only for an animator driven outside the engine loop. A sub-runtime constructed on its own (`createBlinkRuntime()` and friends) still defaults to `performance.now()`. - `MoveCompletedEvent` is pooled per movement component. Read it inside the sink; copy anything you need to keep. - Locomotion clips are CC0 and retargeted offline. Mixamo content is excluded for licensing; `assets/mixamo_to_mh.json` is named for the naming convention, not for the asset source. - `spine_02`, `spine_04` and `neck_02` have no source in a three-joint spine and stay at their rest rotation after a retarget. That is deliberate. # FILE: packages/assets/README.md # gameable/assets ## What The manifest loader and asset registry. Parses and validates `assets.json` against its JSON Schema, resolves string ids to stable handles and URLs, and loads glTF (with Draco and optional KTX2) and audio. Splats and characters plug their own loaders in. ## When to use Any host build. The engine constructs it from the `manifest` option; you rarely call it yourself. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { createAssetRegistry, createDefaultLoaders, parseManifest } from 'gameable/assets'; const manifest = parseManifest({ version: 1, baseUrl: '/assets/', assets: [ { id: 'arena', type: 'gltf', src: 'arena.glb', tags: ['world'] }, { id: 'shot', type: 'audio', src: 'sfx/shot.ogg', tags: ['sfx'] }, ], }); const assets = createAssetRegistry({ manifest, loaders: createDefaultLoaders().loaders }); await assets.preload('world'); console.log(assets.resolve('arena')); // 1 — the handle that crosses the wasm boundary console.log(assets.url('shot')); // '/assets/sfx/shot.ogg' ``` ## API **Manifest** - `parseManifest(json, options?): AssetManifest` — hand-written validation; throws `ManifestError` with a `path` such as `assets[2].collider.radius`. - `loadManifest(url, options?): Promise` — fetch plus parse; `baseUrl` defaults to the manifest's own directory. - `resolveAssetUrl(manifest, entryOrId): string`, `findEntry(manifest, id)`, `joinUrl(baseUrl, src)`. - Types: `AssetManifest`, `AssetEntry`, `AssetType` (`splat | gltf | character | audio`), `AssetCollider`, `AssetRig`, `ExpressionSpace`. **Registry** - `createAssetRegistry({ manifest, loaders?, signal? }): AssetRegistry`. - `AssetRegistry` — `resolve(id): number` (stable 1-based `u32`, `0` = none), `idOf(handle)`, `entry(idOrHandle)`, `url(idOrHandle)`, `load(idOrHandle)`, `get(idOrHandle)`, `preload(tag?)`, `onProgress(cb)`, `registerLoader(type, fn)`, `hasLoader(type)`, `dispose()`. - `AssetError` — carries the `assetId` that failed. **Loaders** - `createDefaultLoaders(options?)` — `{ loaders: { gltf, audio }, dispose() }`, what `createEngine` registers. - `createGltfLoader({ dracoDecoderPath?, ktx2TranscoderPath?, renderer? })`, `createAudioLoader({ fetch? })`, `DEFAULT_DRACO_DECODER_PATH`. - `AssetLoader` — `(url, entry, ctx) => Promise`. ## Gotchas - Ids are the guest/host contract. Renaming an id is a breaking change; changing a `src` is not. - Handles are assigned in manifest order. Reordering `assets.json` renumbers them, which invalidates a saved snapshot. - `audio` resolves to an `ArrayBuffer`, not an `AudioBuffer`: decoding needs an `AudioContext`, which is `gameable/audio`'s business. - The Draco decoder is **not** bundled. Copy `three/examples/jsm/libs/draco/` into your `public/` directory or point `dracoDecoderPath` at a CDN. - `preload` settles every entry before it rejects, so one bad asset does not hide the rest; the failures arrive together in an `AggregateError`. - `load(id)` hands back **one shared promise** per id — the in-flight one while it loads, then the resolved one forever after — so a hot path may call it every frame without allocating. A load that _fails_ is not memoised: the next call really retries. - An unknown id rejects with a **shared** `AssetError`, one per distinct name, so a game that asks for a missing sound sixty times a second does not build sixty stack traces. It is deliberately the same error object each time. - `entry(id)` and `entry(handle)` return the manifest's own entry object, never a copy, and `url()` is precomputed per handle — both are an array index. - `findEntry` memoises its lookup table against the manifest **object**. That is exact rather than convenient: `parseManifest` freezes what it returns, so the entries behind an index can never change. A manifest built by hand and mutated afterwards is not something this package supports. # FILE: packages/assets-aam/README.md # gameable/aam ## What The optional AvatarOS Asset Manager (AAM) adapter. It turns an AAM-hosted character into ordinary `assets.json` entries at runtime, and supplies the `fetch` that knows which URLs the API key belongs to. Nothing else in the engine knows AAM exists. Game logic still addresses assets by string id; the ids simply come from a listing instead of a checked-in file. ## When to use Use it when a character's splat bundle and animation clips live in AAM rather than beside the build — a character that is still being iterated on, or one shared across several games. Do **not** use it for shipped, frozen content: a static `assets.json` needs no key, no network round trip at boot, and no adapter. The adapter is optional in the strongest sense. With `VITE_ASSET_MANAGER_URL` unset, `aamConfigFromEnv()` returns `null` and the game runs unchanged. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. Add the two variables to your game's `.env.example` so the next person knows they exist — and only there. The key itself never goes in a committed file: ```sh # .env.example VITE_ASSET_MANAGER_URL=https://asset-manager.example VITE_ASSET_MANAGER_API_KEY= ``` Real values go in `.env.local` (git-ignored). Leaving `VITE_ASSET_MANAGER_API_KEY` empty is valid: a deployment served from the same site authenticates with its session cookie and no `X-API-Key` header is sent. ## Minimal example ```ts import { loadManifest } from 'gameable/assets'; import { aamConfigFromEnv, buildCharacterManifestEntries, createAamClient, createAamResolver, mergeManifests, } from 'gameable/aam'; let manifest = await loadManifest('/assets/assets.json'); const config = aamConfigFromEnv(); if (config !== null) { const client = createAamClient({ ...config, cache: 'cache-storage' }); const built = await buildCharacterManifestEntries(client, 'myra'); manifest = mergeManifests(manifest, built.entries); // Hand this `fetch` to anything that loads the merged entries. const resolver = createAamResolver(client); const scene = await resolver.fetch(`${built.entries[0].src}scene.json`); console.log(built.characterId, scene.ok); // 'char.myra' true } ``` ## API - `createAamClient({ baseUrl, apiKey, fetch?, retries = 3, backoffMs = 300, cache = 'none' })` — `listAnimationAssets(slug)`, `listCharacterBundle(slug)`, `fetchFile(url, { signal?, version? })`, `resolveFileUrl(path)`, `owns(url)` and the raw `request(input, init?)`. Retries a network error or a 5xx with exponential backoff; a 4xx returns at once. `cache: 'cache-storage'` serves file bytes from the `gameable-aam-v1` bucket when the browser has one. - `buildCharacterManifestEntries(client, slug, { idPrefix = 'char.' + slug, rig?, tags? })` — `{ characterId, entries, faceClips }`. `entries` is a `character` entry whose `src` is the bundle's virtual directory URL, plus a `gltf` entry per body clip. `faceClips` is a JSON list, because ARKit tracks have no manifest type. - `mergeManifests(base, extra)` — appends entries to a manifest. A duplicate id throws rather than letting one definition win. - `createAamResolver(client)` — `{ fetch }` for the `fetch` option of `loadManifest` and `loadCharacterBundle`. - `aamConfigFromEnv(env = import.meta.env)` — `{ baseUrl, apiKey }` or `null`. - `AamError` — carries `path` and, for a failed response, `status`. ## Gotchas - **Never commit the key, never log it.** It is read once from the environment and lives only inside a client closure. It is attached to URLs under `baseUrl` and to nothing else, so a manifest that mixes AAM assets with a public CDN cannot leak it to the CDN. Do not put it in a query string. - Ids are derived from clip **names**, sanitised to the manifest's `^[a-z0-9][a-z0-9._-]*$`. Two clips whose names sanitise to the same id throw; rename one in the Asset Manager rather than working around it here. - The generated `src` values are absolute URLs, so the base manifest's `baseUrl` does not apply to them. That is deliberate: AAM is a different origin. - `cache: 'cache-storage'` is inert under Node and in any browser without the Cache Storage API — the guard is `typeof caches !== 'undefined'`, and a cache read or write failure falls through to the network rather than throwing. The bucket is opened once per client and shared by every file it fetches. - `fetchFile` sends `cache: 'no-store'` **only** in `cache-storage` mode, where this client is the cache and a second unversioned copy in the HTTP cache would be waste. With `cache: 'none'` the browser's own cache is the only one there is, and suppressing it would re-download every asset on every run. - A 5xx that is about to be retried has its body cancelled first. An un-drained response body holds its connection open, which is a socket per retry. - Pass `version` (the row's `updatedAt` or `size`) to `fetchFile` when caching. Without it the entry is keyed by URL alone, and re-uploaded bytes are served from the old cache entry forever. - Building entries costs two requests per character. Do it once at boot, not per spawn. # FILE: packages/assets-aosrig/README.md # gameable/aosrig ## What One character: `assets/aosrig_v0.glb`, a 3.1 MB skinned glTF with a 114-joint skeleton and four embedded clips. | Property | Value | | -------- | --------------------------------------------------------------- | | Geometry | Two primitives, `body` and `head`, sharing one vertex buffer | | Skeleton | 114 joints, Y-up, **+Z forward**, feet at `y = 0`, 1.73 m tall | | Skinning | Linear blend, four influences per vertex, renormalised | | Clips | `idle` (0 m/s), `walk` (1.4), `run` (3.6), `wave` — all looping | | Textures | None. It is lit by whatever lights your scene has | It is a Meta MHR LOD1 body joined to a Google GNM V3 head, exported by the AvatarOS rig exporter. **It is Apache-2.0, not CC0** — `assets/NOTICE.md` has the provenance — which is why it is its own package and not part of `gameable/placeholder` (`AGENTS.md` rule 14). This is the canonical body skeleton. Splat characters are trained against these joint names, so a clip authored for one aosrig character plays on every other one. ## When to use You want a person in the scene instead of a capsule, and you want it to work on the WebGL fallback: the `skinned` rig backend is a plain `AnimationMixer` over a three `SkinnedMesh`, so it needs no WebGPU, no decoder and no ORT. Both game templates use it for their hero, NPCs and enemies. Reach for `gameable/character` and a GNM pack instead when you want a real splat face; reach for your own GLB when you have art. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. The GLB is tracked with Git LFS, so a clone needs `git lfs install` and `git lfs pull` before the bytes are real. ## Minimal example ```ts import { parseManifest } from 'gameable/assets'; import { AOSRIG_ASSETS_BASE, aosrigManifestEntry } from 'gameable/aosrig'; const manifest = parseManifest( { version: 1, assets: [aosrigManifestEntry('char.hero')] }, { baseUrl: AOSRIG_ASSETS_BASE }, ); console.log(manifest.assets[0].src.endsWith('aosrig_v0.glb')); // true console.log(manifest.assets[0].rig?.backend); // 'skinned' ``` Then name `char.hero` in a prefab's `character` field and the host draws the rig where the placeholder capsule was. In a bundled app, import the file with `?url` so it is emitted into `dist/` with a hashed name, and point the manifest entry at that URL instead — that is what both templates do. ## API - `AOSRIG_ASSETS_BASE` — absolute URL of the packaged `assets/` directory, with a trailing slash. Use it as a manifest `baseUrl`. - `AOSRIG_GLB_FILE` — `'aosrig_v0.glb'`. - `aosrigAssetUrl(file)` — the absolute URL of one packaged file. - `aosrigManifestEntry(id, src?)` — the manifest entry, `type: 'character'` with `rig: { backend: 'skinned' }` and no `pack`. - `AOSRIG_JOINTS` — all 114 joint names, in `JOINTS_0` index order. - `AOSRIG_ROOT_JOINT` (`'root'`), `AOSRIG_HEAD_JOINT` (`'c_head'`) and `AOSRIG_HEAD_AIM_JOINTS` (`c_neck` 0.4, `c_head` 0.6) — the names `createAnimator` has to be told, because its defaults are Unreal's. - `AOSRIG_CLIPS` and `AOSRIG_CLIP_SPEEDS` — the four clip names, and the ground speed each locomotion clip was baked at. - `AOSRIG_HEIGHT` — 1.73, the bind-pose standing height in metres. - `AosrigManifestEntry` — the type `aosrigManifestEntry` returns: an alias of `gameable/assets`' `AssetEntry`, not a second declaration of it. ## Gotchas - **Apache-2.0, not CC0.** The rig is a derivative of Meta MHR and Google GNM, both Apache-2.0. Keep `assets/NOTICE.md` and `assets/LICENSE-APACHE-2.0.txt` with the GLB whenever you redistribute it. - **Without Git LFS** the GLB clones as a ~130-byte pointer and every character spawn fails on a bad magic. `src/pack.test.ts` says so in as many words. - **The GLB is an input, not an output.** No script here generates it; it comes from the AvatarOS rig exporter and is copied in. `src/pack.test.ts` is the contract that catches an exporter change. - **Yaw 0 faces +Z**, which is three's `rotation.y` convention and the opposite of the "0 looks down -Z" comment in some older spawn tables. A character that walks backwards is this, every time. - **`aosrigManifestEntry` returns an `AssetEntry`**, so `rig` is optional on the type even though this function always sets it. Read it as `entry.rig?.backend` — that is what `parseManifest` hands back too. - **The animator's default bone names are Unreal's** (`pelvis`, `neck_01`, `neck_02`, `head`). Pass `AOSRIG_ROOT_JOINT`, `AOSRIG_HEAD_JOINT` and `AOSRIG_HEAD_AIM_JOINTS` or the head aim silently does nothing. - **`AOSRIG_JOINTS` order is load-bearing.** It is the `JOINTS_0` index space, not a display list; do not sort it. - **The 34 `*_proc` joints are procedural twists** baked by MHR's own forward kinematics. Pose them by hand and the elbows and knees fold wrong. - **The resolvers need `import.meta.url`**, so they are host-side only. Do not import this package into the QuickJS guest; pass asset ids across the boundary instead. # FILE: packages/assets-placeholder/README.md # gameable/placeholder ## What A 1.0 MB bundle of CC0 placeholder content: a procedural arena splat with a matching collision mesh and spawn table, four synthesised sound effects, an ARKit-52 idle clip and `assets/CREDITS.md`. Every byte is **generated** by `scripts/gen-arena.mjs`, `scripts/gen-sfx.mjs` and `scripts/gen-face.mjs` from seeded maths and the Node standard library. Nothing is downloaded, sampled or traced, so the licensing story is trivially clean: there is no upstream to attribute. | File | What it is | Size | | ---------------------- | -------------------------------------------------------- | ------ | | `arena.spz` | 121,385 gaussians, SPZ v2, 24 x 24 m arena plus sky dome | 899 KB | | `arena.collider.bin` | 98 vertices, 142 triangles | 2.8 KB | | `arena.spawns.json` | 1 player, 6 enemy and 3 pickup spawn points | 950 B | | `shot.wav` | Noise burst with a pitch drop, 0.22 s | 9.5 KB | | `hit.wav` | Body thud with a transient, 0.20 s | 8.7 KB | | `pickup.wav` | Rising A-major arpeggio, 0.54 s | 23 KB | | `step.wav` | Damped footfall, 0.13 s | 5.6 KB | | `face_idle.arkit.json` | 120 frames at 30 fps: one breath, two blinks | 38 KB | | `assets.json` | The manifest, ids `env.arena` and `sfx.*` | 628 B | The arena is Y-up, in metres, with the origin at the centre of the floor: a 24 x 24 m checkered floor, four 3 m walls, six pillars, a crate, a ramp and a dome of large faint gaussians standing in for sky. ## When to use A template or example needs something to render before real content exists. Both shipped scaffolds depend on it, and the engine's own tests use it as a fixture that is guaranteed to parse. Reach for real content the moment you have it. This pack exists so that "nothing renders" is never the first thing a new game does. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. The `.spz` and `.wav` files are tracked with Git LFS, so a clone needs `git lfs install` before the bytes are real. ## Minimal example ```ts import { parseManifest } from 'gameable/assets'; import { arenaSpawns, PLACEHOLDER_ASSETS_BASE, placeholderManifest } from 'gameable/placeholder'; const manifest = parseManifest(placeholderManifest, { baseUrl: PLACEHOLDER_ASSETS_BASE }); console.log(manifest.assets.map((a) => a.id)); // ['env.arena', 'sfx.shot', 'sfx.hit', 'sfx.pickup', 'sfx.step'] console.log(arenaSpawns.player.position, arenaSpawns.enemies.length); // [0, 0, 9] 6 ``` ## API - `placeholderManifest` — a typed copy of `assets/assets.json`: `env.arena` (`splat`, with a `mesh` collider pointing at `arena.collider.bin`) and `sfx.shot`, `sfx.hit`, `sfx.pickup`, `sfx.step` (`audio`). - `PLACEHOLDER_ASSETS_BASE` — absolute URL of the packaged `assets/` directory, with a trailing slash. Use it as the manifest `baseUrl`. - `placeholderAssetUrl(file)` — the absolute URL of one packaged file, for the two documents that cannot be manifest entries. - `arenaSpawns` — a typed copy of `assets/arena.spawns.json`: `bounds`, `player`, six `enemies` and three `pickups`, each `{ position: [x, y, z], yaw }`. - `parseCollider(buffer, { validate? })` / `encodeCollider(positions, indices)` — the `arena.collider.bin` format, documented in `src/collider.ts`. `validate` walks the index array; it defaults to `import.meta.env.DEV`, and to `true` where there is no `import.meta.env` at all. - `ColliderFormatError` — thrown by both when a buffer or a mesh is malformed. - Types: `PlaceholderManifest`, `PlaceholderAssetEntry` (an `AssetEntry`), `PlaceholderCollider` (an `AssetCollider`), `ArenaSpawns`, `ArenaBounds`, `SpawnPoint`, `ColliderMesh`, `ParseColliderOptions`. `npm run generate` rebuilds every asset; `prebuild` runs it, so a build can never ship stale bytes. The generators are seeded, so regeneration is a no-op in `git status`. ## Gotchas - **CC0 only, 12 MB cap** (`AGENTS.md` rule 14). In practice: if a script cannot compute it, it does not go here. Anything with an attribution requirement belongs in the game's own asset directory or in the Asset Manager. - **`face.idle` is not in the manifest.** `docs/schemas/assets.schema.json` fixes `type` to `splat | gltf | character | audio`; an ARKit weight track is none of them, and inventing a type would be a schema change. Load it with `placeholderAssetUrl('face_idle.arkit.json')`, the same way `gameable/aam` returns face clips outside the manifest. `arena.spawns.json` is out for the same reason, and is re-exported as `arenaSpawns`. - **Never edit `assets/`.** Change a generator and rerun `npm run generate`; the committed bytes are outputs (`AGENTS.md` rule 5). - **The entry types are `gameable/assets`' own.** `PlaceholderAssetEntry` and `PlaceholderCollider` are aliases of `AssetEntry` and `AssetCollider`, not copies of them: these entries go straight into `parseManifest`, so a second declaration of the same shape could only drift from the one that is validated. `tags` is therefore optional on the type, even though every entry here has one. - **`parseCollider` skips the index-range scan in a production build.** It is `O(indexCount)` on the loading path and it only ever catches a corrupt file. Pass `{ validate: true }` to force it, `{ validate: false }` to skip it. A bad index is not made safe by skipping the check — Jolt reads the array itself. - **Without Git LFS** the `.spz` and `.wav` files clone as ~130-byte pointer text and the tests fail with `Invalid SPZ magic` or a bad RIFF header. That is the symptom of a missing `git lfs install`, not of a corrupt pack. - **The resolvers need `import.meta.url`**, so they are host-side only. Do not import this package into the QuickJS guest; pass ids and spawn coordinates across the boundary instead. - **Enemies and the player are capsules** until `gameable/character` lands. The pack ships no locomotion GLBs, because nothing here can animate yet. - **Pillars collide as boxes.** The splats draw square pillars and the collider agrees, but if a generator is ever changed to round them, the collision proxy will still be the box. # FILE: packages/audio/README.md # gameable/audio ## What The audio `EngineModule`: a four-bus WebAudio graph (`master` over `sfx`, `music`, `voice`), pooled one-shot playback by sound handle, positional voices through an HRTF `PannerNode`, a listener driven by a position and a quaternion, and an `ended` pool the host turns into `sound-ended` events. It has no three.js dependency — positions and rotations are plain `[x, y, z]` / `[x, y, z, w]` arrays. ## When to use Your game plays sound. Put `audio()` in the engine's module list; the guest emits `play-sound`, `stop-sound` and `set-listener` commands and the host maps them onto `engine.get('audio')`. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { audio } from 'gameable/audio'; // `init` builds the engine and returns it, so it is published as the 'audio' // service. Before `init` there is no engine and no AudioContext at all. const mod = audio({ masterVolume: 0.8 }); const engine = mod.init(ctx); // A play-sound command carries an asset handle, never a URL. The first shot // awaits the decode; every later one reads the settled buffer straight back. const buffer = mod.decodedAsset(pistolHandle) ?? (await mod.decodeAsset(pistolHandle)); engine.play({ id: 1, buffer, pos: [3, 0, -4], volume: 0.9, bus: 'sfx' }); engine.setListener([0, 1.7, 0], [0, 0, 0, 1]); // Once per frame: pump the engine, then drain the ended pool. mod.update(1 / 60, 0); for (const soundId of engine.ended) emitSoundEnded(soundId); engine.ended.length = 0; ``` ## API - `audio(options?)` — the `EngineModule` factory. `id` is `'audio'`, `order` defaults to 30, and `init` returns the engine (so it is published as the `'audio'` service) and installs a one-time `pointerdown`/`keydown` listener that calls `resume()`. `service` is the same engine, and is `null` before `init` and after `dispose`; `decodeAsset(idOrHandle)` bridges a manifest asset to an `AudioBuffer`, and `decodedAsset(idOrHandle)` returns that buffer synchronously once the decode has settled — that is what lets the host play a repeated sound inside the frame that asked for it. - `createAudioEngine({ context?, masterVolume? })` — the engine on its own, for tests and for hosts that already own an `AudioContext`. - Engine surface: `context`, `buses.{master,sfx,music,voice}`, `decode(id, data)`, `play({ id, buffer, pos?, volume?, loop?, bus? })`, `stop(id)`, `setListener(pos, rotQuat)`, `setVolume(id, v)`, `update()`, `ended`, `resume()`, `dispose()`. - `gameable/audio/testing` — `createFakeAudioContext()`, `createFakeAudioBuffer(duration)` and `asAudioContext(fake)`, a dependency-free fake for node tests in any package. - Commands: `play-sound`, `stop-sound`, `set-listener`. - Events: `sound-ended` arrives in the next `frame-input`. ## Gotchas - Browsers keep the context suspended until a user gesture. The module resumes it on the first `pointerdown` or `keydown`; before that, `play` is silent. - `ended` is a pool, not a stream. `update` appends to it and never clears it — the host must do `ended.length = 0` after turning it into events. It stops growing at 256 handles, so a host that never drains it cannot leak. - `ended` lists only voices that ran to their **natural end**, which is why the host can report `completed: true` for every one of them. A voice the game stopped itself — `stop(id)`, or a `play` reusing a live handle — is not listed: the game already knows, and the list has no field to say "stopped early" with. - Looping voices never end on their own. Only `stop(id)` retires them, and that reports nothing. - `update()` times voices on `context.currentTime`, not on the frame delta it used to take: a long frame, a background tab or a `timeScale` change cannot drift playback and bookkeeping apart. A suspended context does not advance, and neither does playback. - Sounds are addressed by manifest id, never by URL. `decode` caches by that id, so decoding the same asset twice costs nothing. - Voices are pooled and `update` allocates nothing, so it is safe in the frame loop. Do not hold on to the `GainNode` behind a voice: it is recycled. - `dispose()` closes the `AudioContext` only when the engine created it. A context you passed in stays yours. `dispose()` before `init` is a no-op — there is nothing to close. ## See also - [Play a sound](../../docs/recipes/play-a-sound.md) - [Engine modules](../../docs/concepts/modules.md) # FILE: packages/cli/README.md # gameable/cli ## What The `gameable` command, and the guest build pipeline behind it. - `dev` runs Vite in direct mode, so an edit to `src/game.ts` is a reload rather than a thirty-second componentize. - `build` runs the whole shipping path — `jco guest-types`, `tsc --noEmit`, `jco componentize`, `jco transpile`, optional `wasm-opt -Oz`, `vite build` — and `--report` measures what it produced and enforces the budgets. - `doctor` checks the toolchain and the manifest, and prints a copy-pasteable fix for every failure. - `docs` says where the `llms*.txt` bundles are on this machine. Everything it spawns is spawned with array arguments and no shell, and every path it hands to jco is absolute and forward-slashed, because Windows paths break shell-string pipelines. ## When to use You are working inside a generated game; the template wires these into its own npm scripts, so you normally type `npm run dev` and `npm run build`. Reach for the package API when you are writing tooling — `gameable/vite` calls `guestBuild` directly. ## Install ```sh npm install --save-dev gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { guestBuild } from 'gameable/cli'; const result = await guestBuild({ gameDir: 'F:/games/my-fps', release: true, onLog: (line) => console.log(line), }); console.log(result.guestEntry); // 'F:/games/my-fps/dist/guest/game.js' console.log(result.wasmBytes); // 2173240 ``` ## API Command line: | Command | Does | | --------------------------------------------------------------- | --------------------------------------------------------------------- | | `gameable dev [--wasm] [--port ] [--host] [--open]` | Vite; `--wasm` builds the component first and sets `GAMEABLE_WASM=1` | | `gameable build [--release] [--report] [--no-wasm] [--no-gate]` | the shipping path; `--ticks ` sets how many ticks `--report` times | | `gameable serve [--direct]` | the room server; see `gameable/rooms` | | `gameable doctor [--quiet]` | eleven checks; the exit code is the number of failures | | `gameable docs [fps\|third-person\|index\|full] [--open]` | where the bundles are | Package: - `guestBuild(options)` — the pipeline. Returns where the outputs landed, the component size and how long it took. - `resolveToolchain(gameDir)` — jco, the WIT package, `gameable` and binaryen, resolved from the game, then the monorepo, then the CLI's own copy. - `generateGuestTypes(toolchain, outDir)` — `jco guest-types` on its own. - `measure({ wasmPath, guestDir, ticks })`, `checkGates`, `formatReport`, `DEFAULT_GATES` — the `--report` numbers, separated from the printing. - `runChecks(deps)` — the doctor, with its filesystem, child processes and module resolution injected, so it tests with fakes and no toolchain. - `parseArgs(argv, spec)`, `filterJcoNoise(text)`, `toPosix`, `relativeSpecifier` — the small pieces, exported because `create-gameable` and the templates need the same behaviour. ## Gotchas - **`wasm-opt` runs after `jco transpile`, not before.** Binaryen cannot parse a WebAssembly _component_ — it says so, loudly — so `--release` optimises the core modules jco unpacked into `dist/guest`. That is the only shape that reaches the browser anyway. - **Red `UNRESOLVED_IMPORT` warnings from componentize are expected.** rolldown bundles before the component is linked, so `gameable:engine/*@0.2.0` genuinely is unresolvable at that point; componentize supplies it afterwards. `filterJcoNoise` strips the blocks, so if you see one, something else printed it. - **`doctor` exits with the number of failures, not 1.** `gameable doctor && …` works; `if [ $? -eq 1 ]` does not. - **`npm` is never spawned as `npm`.** On Windows it is a `.cmd` shim and node refuses to spawn one without a shell, so the doctor runs `node npm-cli.js`. - **The engine packages are loaded through variable specifiers.** `--report` imports the game's own `gameable/host`, and a bundler must not inline either of them into this package. # FILE: packages/conversation/README.md # gameable/conversation ## What Optional Convorcher sessions, versioned story snapshots and synchronized PCM speech presentation. The separate `gameable/conversation/relay` entry is server-only. ## When to use Story-driven NPC interviews. Convorcher owns objectives, personalities, phases and dialogue; the guest receives structural story events through its normal tick. ## Install ```sh npm install --save-exact gameable ``` ## Minimal example ```ts import { conversation, createSpeechPlayer } from 'gameable/conversation'; const context = new AudioContext(); const player = createSpeechPlayer(context, { subtitle: console.log, expression: () => {}, gesture: console.log, speaking: console.log, reset: () => {}, }); const interviews = conversation({ characters: { guide: { storyId: 'guide-story', url: 'ws://localhost:8787/conversation?character=guide' }, }, player, onStory: console.log, onStatus: console.log, onError: console.error, }); // From an explicit interview action; call update each host frame. await context.resume(); interviews.start('guide'); ``` ## API `conversation` provides `start`, `end`, `ask`, `interrupt`, `update` and `dispose`. Revisiting a character reconnects its retained session. `parseStorySnapshot` validates schema version 1; `acceptStorySnapshot` rejects other sessions/stories and old revisions. `createSpeechPlayer` drives subtitles, ARKit-52 expressions and gesture selection from the AudioContext clock. `createConversationRelay` in the `/relay` export creates an HTTP server with `/transcribe` and `/conversation`. Supply server credentials, an explicit origin allow-list and a character/story allow-list; bind to loopback for a desktop prototype. Never import this entry into a browser bundle. ## Gotchas Resume audio in a user gesture. Stream audio and facial data stay in the host; only semantic events cross the guest boundary. The queue caps pending speech at 30 seconds and 64 sentences. Legacy raw/deflated service audio is normalized at the relay. Interrupt/end/dispose clear queued speech and animation. The current Convorcher protocol has no request identifier: supersession uses connection generation and ordered response boundaries. The approved StoryState extension must be deployed for authoritative notebook progression; older servers remain compatible but do not emit snapshots. Animated GNM faces require WebGPU. Prepared performances can be supplied through `characters[id].prefabs`, keyed by the Convorcher `NewResponse.data.prefab` selector. Each value is an array of already-loaded `SpeechChunk`s. Selecting a performance never synthesizes objective completion. Legacy wire turns are serialized; interruption immediately stops playback, while only the newest follow-up question waits for the old response's complete audio count. A 45-second timeout releases the connection if the provider stalls. Trailing Audio2Face frames are bounded and discarded when their audio ends. # FILE: packages/core/README.md # gameable/core ## What The engine host. Owns the canvas, the `WebGPURenderer`, the fixed-step loop, the module registry, the three.js scene graph, the camera rigs, the asset registry and the debug overlay. ## When to use You are bootstrapping an application shell (a template, an example, a custom host). Game code should import `gameable` instead and never touch this package. Server code (a room's authority, a headless test rig) imports **`gameable/core/headless`**, not the package root. The root exports the renderer engine, whose `engine/Engine.ts` imports `three/webgpu`, so importing anything from it loads three. The `/headless` entry carries `createHeadlessEngine` and the three-free rest a server needs: `resolveFeatures`, `bindFeatures`, `FeatureError` and the feature types, `ModuleError`, `EngineModule`, `EngineServices`, `ModuleRegistry`, `HostContext`, `EngineContext` and `requireRenderContext`. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { createEngine } from 'gameable/core'; const canvas = document.querySelector('canvas'); if (!(canvas instanceof HTMLCanvasElement)) throw new Error('no on the page'); const engine = await createEngine({ canvas, manifest: '/assets/assets.json', modules: [], fixedHz: 60, renderer: { backend: 'auto' }, debug: true, }); engine.start(); console.log(engine.ctx.caps.webgpu); // true on WebGPU, false on the WebGL fallback ``` ## API **Engine** - `createEngine(options): Promise` — boot around a canvas. Options: `{ canvas, manifest?, modules?, fixedHz = 60, maxSubsteps = 5, renderer?: { backend: 'auto' | 'webgpu' | 'webgl', antialias?, pixelRatioCap? }, debug?, loaders?, dracoDecoderPath?, ktx2TranscoderPath? }`. - `Engine` — `{ start(), stop(), dispose(): Promise, get(id), resize(w, h), scene, camera, renderer, ctx, assets, graph, events, modules, overlay, loop, running }`. - `resolveEngineConfig(options): EngineConfig` — the defaults, without booting. - `isWebGPUBackend(renderer): boolean` — which backend three actually gave you. - `createHeadlessEngine({ manifest?, modules?, fixedHz = 60, maxSubsteps = 5 }): Promise` — exported from the root and from `gameable/core/headless`, which is where server code takes it. The same modules, loop, clock, events and assets with no renderer, scene or camera (a server tick). `HeadlessEngine` — `{ step(nowMs), start(), stop(), dispose(), get(id), ctx, assets, events, modules, loop, time, running }`; `start()` steps on a drift-free timer, `step` drives it by hand. Modules that need a renderer fail their `init` through `requireRenderContext`. **Module context** - `HostContext` — what every module's `init(ctx)` receives on either engine: `{ assets, events, time, config, caps, registerService(id, service), get(id) }`. No scene, camera or renderer. - `EngineContext` — `HostContext` plus `scene`, `camera` and `renderer`; what `createEngine`'s modules actually get. - `requireRenderContext(ctx, moduleId): EngineContext` — narrow a `HostContext` in a module that needs the scene or renderer; on a headless engine it throws a `ModuleError`. - `isRenderEngine(engine): engine is Engine` — tell an `Engine` from a `HeadlessEngine`, for code (a feature's `bind`) that takes either. **Modules** - `EngineModule` — `{ id, order?, init(ctx), beginFrame?, fixedUpdate?(dt), update?(dt, alpha), endFrame?, dispose() }`. - `createModuleRegistry(): ModuleRegistry`, `ModuleError`. - `EngineServices` / `EngineEventMap` — empty interfaces other packages merge into; see [Engine modules](../../docs/concepts/modules.md). **Features** - `resolveFeatures(features, table): Promise` — load the declared features from a name-to-loader table; an unknown name throws `FeatureError`. - `bindFeatures(loaded, engine): Promise>` — run each feature's `bind` against the booted engine; results keyed by feature name. - `FeatureTable`, `FeatureLoader`, `LoadedFeature`, `FeatureOptions`, `FeatureError`. **Loop, time and events** - `createFixedLoop({ fixedDt, maxSubsteps, fixedUpdate, update, render }): FixedLoop`, `loop.step(nowMs) -> FrameTiming`. - `createTime(fixedDt): MutableTime` — `{ now, elapsed, fixedDt, frame, timeScale }`. - `createEvents(): Events` — `on` / `once` / `off` / `emit`, no allocation on emit. **Scene and cameras** - `TransformStore` — prev/curr transforms in typed arrays; `set`, `commit()`, `writeInterpolated(id, object3d, alpha)`, `snap(id)`. - `SceneGraph` — `spawn(id, object, parentId?)`, `despawn(id)`, `get(id)`, `setParent(id, parentId)`; `NO_ENTITY` is `0`. - `createFirstPersonRig({ camera, eyeHeight?, maxPitch? })` — `setPose(position, yaw, pitch)`. - `createThirdPersonRig({ camera, pivotHeight?, minDistance?, collisionPadding?, collisionProbe? })` — `setTarget`, `setOrbit(yaw, pitch, distance)`, spring arm shortened by the probe. **WebGPU and debugging** - `initWebGPUPatches({ maxStorageBuffersPerShaderStage?, timestampQuery? })` — call before `renderer.init()`. - `createDebugOverlay({ renderer, backendName })` — F3 panel; `createFrameStats(n)` is the window behind it. `candleFlicker(seconds, fixtureSeed)` returns a deterministic, continuous intensity gain in [0.58, 1.2]. The application owns point lights, positions and base intensity. `createFpsCounter(engine, element)` counts rendered frames into a supplied text element, resets on tab visibility changes, and returns a cleanup callback. It adds no UI unless explicitly called; keep markup and Gameable styling in the application. ## Gotchas - `initWebGPUPatches()` is an explicit call in bootstrap; `createEngine` makes it for you, but a custom host must make it itself, **before** any `GPUDevice` exists. - The loop runs `fixedUpdate` at 60 Hz with at most 5 substeps, then one interpolated `update`. Do not allocate in either. - Frames beyond the substep cap are **discarded**, not owed. `FrameTiming.clamped` tells you it happened. - Server code must not import the package root: it loads three. Use `gameable/core/headless`; `packages/wasm-host/src/server/threeFree.test.ts` holds the server's import graph at zero three loads. - GNM and decoder characters need WebGPU. On the WebGL fallback `caps.characters` is `false`; check it rather than letting `createCharacter` throw. Exported (`aosrig-splat`) characters draw there too, through the character bridge. - `engine.dispose()` returns a promise, because `renderer.dispose()` does. Await it before creating another engine on the same canvas. # FILE: packages/create-gameable/README.md # create-gameable ## What The scaffolder behind `npm create gameable`. It copies `templates/fps`, `templates/third-person` or `templates/mystery`, substitutes `{{name}}`, `{{title}}` and `{{aosVersion}}`, rewrites the template's workspace dependencies into something a standalone game can install, writes `.env.example`, a `.gitignore` and a game-scoped `AGENTS.md`, then runs `git init` and `npm install`. It has **no runtime dependencies**. `npm create` downloads this package and its tree before it prints anything, so the argument parser and the path helpers are small deliberate copies of `gameable/cli`'s rather than an import of it. ## When to use You are starting a new game. This is the first command in the quickstart. ## Install ```sh npm create gameable my-game -- --template fps ``` There is nothing to install; `npm create` fetches it. The package API is there for tooling that wants to scaffold without spawning a process. ## Minimal example ```ts import { findTemplate, scaffold } from 'create-gameable'; const template = findTemplate(process.cwd(), 'fps'); if (template) { const result = scaffold({ targetDir: 'F:/games/my-fps', templateDir: template.dir, manifest: template.manifest, name: 'my-fps', title: 'My FPS', versions: { mode: 'semver', version: '0.4.2', gameDir: 'F:/games/my-fps' }, aam: false, }); console.log(result.files); // ['.env.example', '.gitignore', 'AGENTS.md', …] } ``` ## API Command line: ```sh npm create gameable my-game -- --template fps [--third-person] [--title "…"] [--list] [--no-install] [--no-git] [--aam] [--force] ``` | Flag | Does | | ------------------- | ---------------------------------------------------------------- | | `--template ` | `fps` (default), `third-person`, `visit`, or a multiplayer kit | | `--list` | print every template, kits marked `[multiplayer]`, one line each | | `--third-person` | shorthand for `--template third-person` | | `--title ""` | page title and README heading; defaults to the directory name | | `--no-install` | skip `npm install` | | `--no-git` | skip `git init` | | `--aam` | add `VITE_ASSET_MANAGER_URL` and `_KEY` to `.env.example` | | `--force` | write into a directory that already has files in it | Package: - `scaffold(options)` — the copy, substitution and dependency rewrite. - `listTemplates(cwd)`, `findTemplate(cwd, name)`, `readManifest(dir, name)` — what is installed, and what each template declares in its `template.json`. - `applyTokens(text, tokens)`, `leftoverTokens(text)`, `toPackageName(raw)`, `toTitle(name)` — the substitution, testable on its own. - `engineDependency(name, spec, ctx)`, `gamePackageJson(template, name, ctx)` — `"gameable": "0.0.0"` becomes `^0.4.2`, or a `file:` link back into a checkout of the engine. - `main(argv, cwd)` — the whole command, returning an exit code. ## Gotchas - **Templates are real workspace members**, typechecked, linted and tested in CI before anyone copies one. `scripts/sync-templates.mjs` copies them into `dist/templates` at `prepack` and removes them again at `postpack`, so the published copy is the tested copy and a generated copy of somebody else's files never lingers in a working tree. Inside a checkout the authored `templates/` win; `GAMEABLE_TEMPLATES` overrides both. - **`.gitignore` travels as `_gitignore`.** npm refuses to put a `.gitignore` inside a package tarball, so the sync renames it on the way in and the scaffolder renames it back on the way out. - **Run it from inside a checkout of the engine and you get `file:` links** back at `packages/` instead of published versions, so an engine change shows up in the game without a publish. Outside a checkout you get `^`. - **Prompts only appear when a flag is missing and stdin is a TTY.** CI and agents get the defaults and are never asked a question nobody can answer. - **The generated game must run with zero edits and no Git LFS.** If it does not, that is a bug in the template, not in your setup. # FILE: packages/gameable/README.md # gameable **Gameable Engine**: a browser game engine where the world is a gaussian splat, the renderer is WebGPU, and all game logic is a WebAssembly component. [Documentation](https://engine.gameable.com) · [Play the examples](https://engine.gameable.com/play) · [Source](https://github.com/getgameable/gameable-engine) ## What The whole engine in one package. Game code imports only `gameable` (the SDK: `defineGame`, the ECS, the input, physics and HUD facades). The page that hosts the game imports the rest: `gameable/core`, `gameable/host`, `gameable/splat`, `gameable/character`, `gameable/physics`, `gameable/input`, `gameable/audio`, `gameable/assets`, `gameable/net`, `gameable/rooms`, `gameable/conversation`, `gameable/vite`, `gameable/test`, `gameable/three` and more. The `gameable` command (`npx gameable dev | build | serve | doctor | docs`) comes with it. ## When to use - Starting a game: use `npm create gameable`, which installs this package and wires up a template. - Putting a character made in the [Gameable studio](https://app.gameable.com) into your own three.js app: install this package and use `gameable/three`. ## Install ```sh npm create gameable my-game -- --template fps ``` or, into an existing project: ```sh npm install gameable three@0.186 ``` ## Minimal example `src/game.ts` in a new game: ```ts import { defineGame, input, physics, hud } from 'gameable'; export default defineGame({ assets: './assets.json', world: 'arena', player: { spawn: [0, 1.7, 0], speed: 5, jump: 4.5 }, systems: [ (ctx) => { if (input.pressed('fire')) { const hit = physics.raycast(ctx.camera.origin, ctx.camera.forward, 100); if (hit) ctx.damage(hit.entity, 25); } hud.set({ ammo: ctx.player.ammo, health: ctx.player.health }); }, ], }); ``` ## API The [API reference](https://engine.gameable.com/api/) lists every export of every subpath. `entries.mjs` in this package is the table of subpaths. ## Gotchas - `three` is a peer dependency (`>=0.186.0 <0.187.0`). Import it from `three/webgpu`, never bare `three`. - Tooling some tasks need is an optional peer: `@bytecodealliance/jco` and `componentize-qjs` build the WebAssembly guest (the templates install them); `pg`, `express`, `ws` and the Colyseus server packages run rooms. - Node.js 24 and a browser with WebGPU are required. - Licence: MIT. The sample rig (`gameable/aosrig`, `assets/aosrig_v0.glb`) is Apache-2.0, derived from Meta's MHR and Google's GNM; `THIRD_PARTY_NOTICES.md` lists every third-party work. # FILE: packages/input/README.md # gameable/input ## What The input `EngineModule`. It attaches the DOM listeners, collects keyboard, mouse (including pointer-lock deltas) and gamepad into the packed `input-state` block of `frame-input`, and resolves named actions like `fire` and `move`. The state is exactly the WIT record, so it crosses the wasm boundary without a translation layer: - `keysDown` / `keysPressed` / `keysReleased` — 256 bits each, in `Uint32Array(8)`. Key `c` is bit `c & 31` of word `c >> 5`. - `mods` — `Mods.Shift | Mods.Ctrl | …`, in `input-mods` flag order. - `mouse` — `{ x, y, dx, dy, wheel, buttons, pressed, released, locked }`. - `gamepads` — preallocated slots of `{ index, connected, buttons, pressed, released, axes: Float32Array(6) }`. - `focused` — false means "treat input as neutral". The index table in `src/keycodes.ts` maps `KeyboardEvent.code` to bit index and is **frozen forever**: recordings, replays and compiled guests encode those numbers, so codes may only ever be appended. Indexes 0..127 are the shared keyboard table, imported from `gameable/sdk/keycodes` rather than copied, so host and guest agree bit for bit by construction. 128..247 are free for appends. Mouse buttons are mirrored into the reserved pointer block at the top (`Mouse0`..`Mouse4`, 248+), which the guest table leaves empty, so `'LMB'` reads exactly like `'Space'`. ## When to use Always. Every template registers it. Game code inside the guest should read it through `sdk`'s `input` facade rather than importing this package directly; host-side code — a camera rig, a debug overlay, an editor tool — reads the service. Use `createInputCapture` on its own when you want the packed state without the engine (a test harness, a record/replay tool, a standalone viewer). ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { input } from 'gameable/input'; const module = input({ pointerLock: true, actions: { fire: ['LMB', 'GamepadRT'], jump: ['Space', 'GamepadA'], move: { axis2: ['A', 'D', 'S', 'W'], gamepadAxes: [0, 1] }, }, }); // The engine calls init, then beginFrame once per rendered frame, fixedUpdate // once per simulation step, and endFrame after rendering. const { actions, state } = module.init(ctx); // Resolve the handles once, at startup; never look an action up by name in a // frame body. const JUMP = actions.handle('jump'); const MOVE = actions.handle('move'); module.beginFrame(); module.fixedUpdate(1 / 60); if (actions.pressed(JUMP)) console.log('jump'); const [x, y] = actions.axis2(MOVE); console.log(x, y, state.mouse.dx, state.mouse.dy); module.endFrame(); ``` ## API - `input(options?): InputModule` — the `EngineModule` factory. `id` is `'input'`, `order` is `-100`, and `init` returns the service, so the engine publishes it as `engine.get('input')`. `options`: `target`, `actions`, `pointerLock`, `preventDefault`, `gamepads`, `gamepadSlots`, `focused`, `onLockDenied`. - `InputService` — `{ state, actions, requestPointerLock, exitPointerLock, lockDenied, consume }`. - `createInputCapture(target, options?)` — the DOM layer: `{ state, lockDenied, beginFrame, consume, endFrame, requestPointerLock, exitPointerLock, dispose }`. `beginFrame` folds the frame's DOM events into the pending edges and publishes the level state; `consume` hands one fixed step the edges and mouse deltas that have accumulated since the previous step and clears them; `endFrame` clears the published edges. - `createActionMap(bindings, state?)` — returns `{ names, handle, down, pressed, released, axis2, bind }`. `handle(name)` mints the small integer the four readers also accept, so a frame body does no string work at all. Bindings are `'KeyW'`, `'W'`, `'Space'`, `'LMB'`, `'RMB'`, `'GamepadA'`, `'GamepadRT'`, … An axis2 action is `{ axis2: [negX, posX, negY, posY], gamepadAxes?, deadzone?, invertGamepadY? }`. - `createInputState(slots?)`, `isDown`, `wasPressed`, `wasReleased`, `axis2(state, negX, posX, negY, posY, out?)`, `setBit`, `clearEdges`, `resetInputState` — the pure state layer. - `keyIndex(nameOrCode)`, `keyIndex2` (the other half of `'Shift'`), `KEY_INDEX`, `KEY_NAMES`, `pointerKeyIndex`, `gamepadButtonIndex`, `GAMEPAD_BUTTON_INDEX`, `Mods`, `MouseButtons`. ## Gotchas - Text fields, selects and contenteditable descendants own their keyboard input: typing does not produce gameplay keys. Focusing a field releases held gameplay input and cancels pending presses, so movement cannot stick while typing. - `pressed` and `released` belong to a **fixed step**, not to a rendered frame. They are published by `fixedUpdate` (`capture.consume()`) and cleared by `endFrame`, so read them from a `fixedUpdate`, never from `update`. A frame that runs no fixed step publishes no edges and loses none: they wait. - A frame that runs several fixed steps delivers each edge to the **first** step only. That is deliberate — it is what a 60 Hz display would have shown, and it stops one press from firing a weapon twice on a slow frame. - `mouse.dx` / `dy` / `wheel` follow the same rule: they accumulate across every rendered frame since the last step and are handed over whole, exactly once. - A tap that starts and ends inside one frame still reports both edges, with `down` false. Test `pressed`, not `down`, for a fire button. - `axis2` returns a **reused** tuple. Destructure it or copy it; do not keep the reference. Pass your own `out` if you need two axes at once. - Pointer-lock deltas are only non-zero while the lock is held, and `requestPointerLock` only succeeds inside a user gesture. With `pointerLock: true` a `mousedown` on the target requests it for you. - `wheel` is in lines, not pixels: a pixel-mode wheel event is divided by 100. - Blur clears everything held, so a key does not stick down while the tab is in the background. The release edge arrives on the next fixed step. - Gamepads are only polled once a `gamepadconnected` event has fired, which in every browser means after the player has pressed a button on the pad. - The key table is append-only. Adding a code in the middle silently reinterprets every recording ever made. - The keyboard half of the table is not written here. `src/keycodes.ts` imports `KEY_NAMES` from `gameable/sdk/keycodes` — a dependency-free table module that pulls in no game runtime — and builds the host index from it, so host and guest cannot drift. Indexes 128..247 and the pointer block are the host's own. See [ADR 0013](../../adr/0013-single-key-table.md). - `down` / `pressed` / `released` / `axis2` take either a name or a handle. Resolve the handle once with `actions.handle('jump')` and keep it: the name path costs a map lookup per read, the handle path is an array index. - The capture only folds DOM events when a handler has actually seen one, so a frame with no input costs a flag test. Nothing observable changes: `fold` was already idempotent. - `focused` seeds from `document.hasFocus()`. jsdom answers `false` for a document nobody clicked, which switches `preventDefault` off, so a headless test that dispatches keys at the window should pass `focused: true` (or dispatch a `focus` event first). - A refused pointer lock is reported, not thrown: `capture.lockDenied` goes true and `onLockDenied` fires, for the `pointerlockerror` event and for a rejected `requestPointerLock()` alike. Show "click to play" rather than assuming the mouse is captured. - The module imports `EngineModule` / `EngineContext` from `gameable/core` and merges `InputService` into core's `EngineServices`, so `engine.get('input')` is typed with no cast. Core does not depend on input, so there is no cycle. The capture and state layers stay engine-free: import `createInputCapture` on its own in a harness or a recorder. # FILE: packages/physics-jolt/README.md # gameable/physics ## What The physics `EngineModule`, backed by `jolt-physics` 1.1 compiled to wasm. Rigid bodies, `CharacterVirtual` controllers, raycast / raycast-batch / overlap-sphere queries, contact events and a wireframe debug view. Body state comes back as a packed stride-15 `Float32Array` — the exact shape of the WIT `frame-input.bodies` buffer — so the host never builds an object per body per frame. ## When to use Your game needs collision, gravity, a character controller or hitscan raycasts. Register `physics()` in the engine module list and drive it with the physics commands in `frame-output`. Use `createPhysicsWorld` directly only in tools and tests: a headless collider baker, a determinism harness, a benchmark. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. The wasm binary is a **separate file**. Under node it is found automatically; in a browser build, hand the module the URL your bundler minted: ```ts import wasmUrl from 'jolt-physics/jolt-physics.wasm.wasm?url'; physics({ wasmUrl }); ``` ## Minimal example ```ts import { createEngine } from 'gameable/core'; import { physics } from 'gameable/physics'; const engine = await createEngine({ canvas, manifest: '/assets/assets.json', modules: [physics({ gravity: [0, -9.81, 0] })], }); const world = engine.get('physics'); // A static floor and a crate to drop onto it. world.addBody({ id: 1, shape: 'box', dims: [20, 0.5, 20], position: [0, -0.5, 0], rotation: [0, 0, 0, 1], mass: 0, kind: 'static', layer: 0b0001, mask: 0xffff, friction: 0.8, restitution: 0, }); world.addBody({ id: 2, shape: 'box', dims: [0.5, 0.5, 0.5], position: [0, 5, 0], rotation: [0, 0, 0, 1], mass: 25, kind: 'dynamic', layer: 0b0010, mask: 0xffff, friction: 0.5, restitution: 0.1, // Contacts are off by default; this crate is one the game reacts to. flags: { reportContacts: true }, }); // Per frame: read the packed body rows, then ask a question or two. const bodies = new Float32Array(64 * 15); const rows = world.readBodies(bodies); const hit = world.raycast([0, 2, 0], [0, 0, -1], 100, 0xffff); if (hit) console.log(`shot body ${String(hit.body)} at ${hit.distance.toFixed(2)} m`); ``` ## API **Module** - `physics(options?)` — the `EngineModule` factory. `id: 'physics'`, `order: 0`, `fixedUpdate` steps the world and then fires `physics:stepped`. Options: `gravity`, `maxBodies`, `layers`, `substeps`, `maxContactsPerStep`, `wasmUrl`. - `service.debugWireframe(scene | null)` — attach or detach the line view. - `'physics:stepped'` — engine event, one `PhysicsSteppedEvent` per fixed step, fired the moment body state is post-step. The payload is reused; never retain it. This is where the host reads the rows it draws bodies from. **World** - `loadJolt({ wasmUrl? })` — instantiate the wasm module, once per page. - `createPhysicsWorld(jolt, options?)` — a bare `PhysicsWorld`. - `addBody(args)`, `removeBody(id)`, `setTransform`, `setVelocity`, `applyImpulse`, `setEnabled`, `moveCharacter`, `groundState` — one per WIT physics command. - `step(dt, substeps?)` — advance, then queue this step's contacts. - `readBodies(out)` — stride 15: `[id, pos×3, quat×4, linVel×3, angVel×3, groundState]`, ascending by id, enabled non-static bodies only. Returns the rows it _wanted_; size `out` from `movingBodyCount` and it never has to be called twice. A sleeping body replays its last row rather than crossing for it again. - `drainContacts(out)` — pooled `ContactRecord`s, `begin` / `stay` / `end`, for the bodies that asked (`flags.reportContacts`, plus `flags.reportStay` for `stay`). - `raycast`, `raycastBatch`, `overlapSphere`, `overlapSphereInto` — the only synchronous host imports. Batch stride in 7, out 9. - `bodyCount`, `movingBodyCount`, `revision`, `bodyIds()`, `readBodyBounds()`, `readBodyPose()` — for debug views and for sizing a read. - `dispose()` — frees every Jolt object. **Shapes** - `meshShapeFromGeometry(jolt, positions, indices)` — static triangle collider. - `convexHullFromPoints(jolt, points)` — dynamic-capable hull, for splat props. ## Gotchas - **Physics steps inside `fixedUpdate`, never in `update`.** Reading body state in `update` gives you the interpolated pose. - **Contacts are opt-in, and `stay` is opt-in again.** A body reports nothing unless `flags.reportContacts` says so, and even then only `begin` and `end` until `flags.reportStay` is set too. One flagged side of a pair is enough. The default is off because a resting stack generates a manifold per pair per step whether or not anyone reads it. A step that exceeds `maxContactsPerStep` (256) drops the surplus and warns once. - **A disabled character is not really disabled.** `setEnabled(id, false)` stops a `character` body being stepped and being read, but its inner body belongs to the controller and stays in the broad phase, so things still bump into it. Remove it instead when it has to stop existing. - **Jolt frees nothing.** It is C++ behind emscripten: every `new jolt.X` needs a matching `jolt.destroy`, and every reference-counted object an `AddRef`/`Release` pair. `world.dispose()` is the one place in this package that has to get that right; if you build shapes yourself, `Release()` them. - **Returned objects are reused.** `raycast` hands back one `RayHit` instance and `drainContacts` fills your pool in place. Copy what you need before the next call; do not keep the reference. - **`layer` and `mask` are bitsets, and both must agree.** Two bodies collide only when `a.mask & b.layer` _and_ `b.mask & a.layer` are non-zero. Each distinct `(layer, mask)` pair costs one Jolt object layer out of `layers.maxObjectLayers` (64 by default, half static and half moving). - **Mesh colliders respect winding.** Jolt ignores mesh back faces, so a ray can pass straight through a triangle wound the wrong way. Faces must be counter-clockwise seen from the front. - **Queries are synchronous and therefore cheap to over-use.** Batch them: `raycastBatch` is one call for N rays. `overlapSphere` allocates its result array — use `overlapSphereInto` in anything that runs per step. - **A character is not a rigid body.** `moveCharacter` takes horizontal velocity literally (no inertia) and treats a positive `y` while grounded as a jump; gravity is integrated for you while airborne. Impulses do nothing to it. # 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: packages/splat/README.md # gameable/splat ## What Gaussian splat rendering, on three.js r186's native `GaussianSplat` and a `WebGPURenderer`. The static path wraps three's SPZ / PLY / SPLAT / KSPLAT / glTF loaders; a linear, unlit splat without shadows is three's own `GaussianSplat`, anything else (an sRGB splat, the default; one that is lit or receives shadows) is the fork below. The dynamic path is a maintained, insertion-only fork of `GaussianSplat.js` that allocates a fixed capacity of gaussians with **no CPU copy**, exposes the four storage buffers as real `GPUBuffer`s, and can force a re-sort — so a compute shader owns the gaussians and nothing crosses the bus per frame. This package also owns every line of private three internals in the repository, in `src/backendBuffers.ts` (`AGENTS.md` hard rule 8). ## When to use You are rendering a splat world, or you are writing a producer — a character lift — that writes gaussians directly into GPU memory. Game code usually does neither: it declares a `splat` asset in `assets.json` and lets the `splat()` module do the rest. ## Install ```sh npm install gameable ``` Inside this repository it is a workspace member and needs no install. `three` is a peer dependency, pinned exactly at `0.186.0` by the root `overrides` — the fork is a copy of one file from that exact version and the build refuses to drift from it. ## Minimal example ```ts import { createEngine } from 'gameable/core'; import { splat } from 'gameable/splat'; const engine = await createEngine({ canvas, manifest: { version: 1, assets: [{ id: 'arena', type: 'splat', src: '/arena.spz' }] }, modules: [splat()], }); await engine.assets.load('arena'); engine.get('splat').add('arena'); engine.start(); ``` Without the engine, for a producer: ```ts import { createAnimatedSplat, getGPUDevice } from 'gameable/splat'; const sink = await createAnimatedSplat(renderer, { capacity: 250_000, boundingSphere: { center: [0, 1, 0], radius: 1.4 }, }); scene.add(sink.object3D); const head = sink.allocate(120_000); // { offset: 0, count: 120000 } // ... a compute pass on getGPUDevice(renderer) writes sink.buffers.* over that range ... sink.markGaussiansChanged(); // once per frame, after the pass ``` ## API **Static.** `loadSplat(url, { format?, signal? })` → `SplatAsset { geometry, count, shDegree, boundingSphere, format, url }`. `parseSplat(buffer, url, format?)` for bytes you already have, `sniffSplatFormat(url, bytes)` for the decision on its own. `createSplatObject(asset, { autoSort?, colorSpace?, environmentLighting? })` → a splat object with bounds computed and `renderOrder` set. `registerGltfSplatExtension(gltfLoader)` teaches a `GLTFLoader` `KHR_gaussian_splatting`. **Dynamic.** `createAnimatedSplat(renderer, { capacity, boundingSphere?, colorSpace?, kernel?, renderOrder? })` → `AnimatedSplat`, which implements `SplatSink`: `capacity`, `allocate(count) → SlotRange`, `free(range)`, `buffers`, `markGaussiansChanged()`, `setBoundingSphere(center, radius)`, `object3D`. Plus `clearSlots(range?)` and `dispose()`. `createSlotAllocator(capacity)` is the allocator on its own. **Colour space.** `colorSpace: 'srgb'` is the default for both: nearly every splat is trained blending its stored sRGB colours, and three blends in linear light. An sRGB splat is drawn only by the sRGB pass, `attachSrgbPass(renderer, scene)` from `gameable/core` (or `gameable/core/render`), which blends it on sRGB values inside your own render; without a pass it draws nothing and warns once. The engine attaches the pass to its scene at boot. `colorSpace: 'linear'` is for a splat trained in linear light; it draws in your render directly. `kernel: 'studio'` draws with the Gameable studio's kernel (exported characters use it). **Backend.** `getGPUDevice(renderer)`, `isWebGPUBackend(renderer)`, `acquireSplatGPUBuffers(renderer, splat)`. The layout the buffers use is documented in full at the top of `src/backendBuffers.ts`. **Module.** `splat()` → an `EngineModule` that registers the `splat` asset type and publishes a `splat` service with `add(id)` (it receives shadows) and `added`. **Shadows.** `createSplatShadows(renderer, scene, { quality?, characterBias?, placeBias? })`: `addCharacter`, `addPlace(splat, { collider?, panorama? })`, `setQuality(level)`, `setKeyLight`, `update(dt)`, `info`, `dispose`. The depth maps are drawn again only when something in them moved (`info.rendered`); characters standing apart get a light camera each, up to four tiles of one map (`info.tiles`, `clusterShadowCasters`). The biases are metres: 0.12 on a character (its gaussians sit a little inside the rig mesh that casts), 0.03 on the place. The character bridge runs one for you. The key light's estimators (`estimateKeyLightFrom{Panorama,Surfels}`, `panoramaFromGaussians`) are exported too. **Bench.** `gameable/splat/bench` exports `benchStatic`, `benchDynamic` and the synthetic `ANIMATE_WGSL` producer. Drive them from `examples/splat-viewer/?bench=1`. Enable relighting through the standard service after loading the asset: ```ts const world = engine.get('splat').add('arena', { environmentLighting: { radianceSH: probe.radianceSH, emissionWeight: 0.25 }, }); ``` `createSplatObject(asset, { environmentLighting })` supports the same opt-in. Omitting the option draws the splat unlit. The caller owns the returned object: remove it from the scene and dispose it before disposing the engine. `createEnvironmentProbe(scene, renderer, panorama, options)` filters a caller-owned HDR panorama for mesh IBL and scales matching world-space SH for Gaussian lighting. Its cleanup restores the previous scene environment and frees the filtered texture; the caller disposes the original panorama. `yaw` rotates the panorama only: rotate SH into world space during probe preparation. ## Gotchas - **Splats draw after opaque geometry with depth test on and depth write off.** The sRGB splats reach the frame as one transparent layer at the nearest splat. Transparent meshes must not intersect splat volumes, and two overlapping splat objects will interleave incorrectly — nothing sorts across objects. One character is one `AnimatedSplat`, with every branch in a slot range, for exactly this reason. - **`markGaussiansChanged()` every frame you write gaussians.** Three only re-sorts when the view direction moves more than ~1.81°, so a moving avatar in front of a still camera renders in a stale order. It looks almost right, which is the problem. - **The bounding sphere is a promise, not a measurement.** The sort's depth range comes from it and frustum culling is off because of it. Keep it current. - **Capacity is fixed.** Growing would mean reallocating four GPU buffers and every bind group pointing at them. Size for the worst case; unallocated slots are zero, and zero is invisible. - **On the WebGL fallback, write with TSL.** `sink.buffers` throws there; a TSL compute node over `sink.nodes`, dispatched once per frame over all of `sink.storageCapacity`, writes the splat on both backends. The fallback sorts on the CPU from a fenced readback of the centres, read at most every 50 ms. Static splats sort on the CPU there too, about 1.7 ms per 150 k gaussians. - **The fork is byte-checked against upstream.** `npm test -w packages/splat` runs `scripts/diff-upstream.mjs` first; every change to `src/three-fork/` must be inside an `GAMEABLE EDIT` block or the check fails. See `src/three-fork/UPSTREAM.md`. - **Shadows come from rig meshes.** The silhouette is the body's (no big hair); a floor's shadow edge follows its gaussians (one value each). The light is read once, never per frame. - **`npx playwright test -c tests/e2e/playwright.config.ts`** runs the end-to-end suite (`npx playwright install chromium` once first). A root `test:e2e` script still needs to be added, and `src/three-fork/**` still needs a `.prettierignore` entry — the fork is byte-for- byte upstream and Prettier would reformat it into permanent drift. `AnimatedGaussianSplat.setEnvironmentLighting()` optionally mixes captured emission with diffuse image-based lighting. Supply nine world-space RGB radiance SH coefficients (three's `SphericalHarmonics3` order), `emissionWeight` and `diffuseWeight`. Normals follow the current covariance on the GPU; an authored local `normalOrigin` or the current bounding-sphere centre chooses the outward hemisphere. `inputColorSpace: 'srgb'` decodes display-referred source colors. Pass `null` to restore the original shader. Configure when the probe changes, not per frame. This is approximate relighting of baked colors: no inverse rendering, recovered material properties, occlusion or path tracing is implied. Supply `pointLights: [lamp]` to include up to sixteen three `PointLight` objects. Their position, color, intensity, distance, decay and visibility remain live via uniforms; animate the lights normally without calling `setEnvironmentLighting` again. `twoSided: true` lights both sides of approximate normals in room interiors. `pointLightSoftness` bounds near-source attenuation (default 0.25 world units). For local lights alone, provide nine zero SH coefficients. Static geometry passed to `new AnimatedGaussianSplat(asset.geometry)` supports this on both renderers; dynamic GPU-only geometry still requires WebGPU. Point lights do not cast splat shadows or remove baked illumination. # FILE: packages/test-harness/README.md # gameable/test ## What Test doubles for the wasm boundary: a mock host implementing every `gameable:engine` import, host-shaped `frame-input` builders with key and mouse helpers, a frame driver, and the hashes the determinism and parity tests compare. ## When to use You are writing vitest tests for a game module, or for an engine package that talks to a sandbox. Everything here is node-only and has no engine dependency. ## Install ```sh npm install --save-dev gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { expect, it } from 'vitest'; import { createGuest } from 'gameable'; import { createFrameInput, createGameConfig, createMockHost, press, simulate } from 'gameable/test'; import game from '../src/game.ts'; it('spawns the player and three enemies on frame 0', () => { const host = createMockHost({ seed: 42, assets: ['arena', 'enemy-capsule'] }); const guest = createGuest(host, game); guest.init(createGameConfig({ seed: 42n })); const out = guest.tick(createFrameInput({ frame: 0 })); expect(out.commands.filter((c) => c.tag === 'spawn')).toHaveLength(4); }); it('is deterministic', () => { const script = (frame, input) => { if (frame === 10) press(input, 'W'); }; const a = simulate(makeGuest(), { frames: 600, script }); const b = simulate(makeGuest(), { frames: 600, script }); expect(b.hashes).toEqual(a.hashes); }); ``` ## API - **`createMockHost({ seed, nowMs, raycast, overlapSphere, assets, strictAssets })`** — a `HostApi` that records every `env.log` line (`log_`, `warnings()`) and every query (`rayCalls`). `assets` is the manifest: a name that is not in it resolves to nothing, because `strictAssets` defaults to **true** — a typo'd asset id should fail a test, not mint a handle. Pass `strictAssets: false` for a test that does not care which assets exist; `handleOf(name)` registers one on demand either way. - **`createFrameInput(overrides)` / `createGameConfig(overrides)` / `createInputState()`** — host-side shapes: `frame` and `seed` are `bigint`s, key bitsets are `Uint32Array`s, absent options are `undefined`. - **`press(state, key)` / `release(state, key)` / `endFrame(state)` / `pressMouse` / `releaseMouse`** — write the bitsets the way `gameable/input` does. Key names come from `gameable/sdk/keycodes`, so `'W'`, `'KeyW'` and `'Shift'` all work. - **`packBodies(rows)`** — build a stride-15 `bodies` list, sorted by body id. - **`simulate(guest, { frames, dt, script, keepOutputs })`** — drive a guest or a `Sandbox` and collect per-frame hashes, command tags, HUD payloads and transform row counts. - **`hashFrameOutput(out)`, `hashTransforms`, `hashCommands`, `stableJson`** — FNV-1a over the raw f32 bit patterns and a key-sorted JSON rendering of the commands. ## Gotchas - **Determinism tests must run the tape twice**, and ideally again across a `snapshot` / `restore` cycle. One run proves nothing. - **`simulate` reuses one input state and clears its edges between frames**, so a `press` in the script stays held until you `release` it — exactly like the real input module. - **The mock host is not a physics engine.** Queries return whatever the options say; `bodies` is whatever you pack. - **Frame outputs are reused objects.** `keepOutputs` collects references to the same record, so hash as you go rather than comparing outputs afterwards. # FILE: packages/vite-plugin-gameable/README.md # gameable/vite ## What The Vite plugin every Gameable Engine app uses. It is one line in `vite.config.ts` and it replaces half a dozen unrelated settings, every one of which fails in a way that looks like something else: | Setting | What goes wrong without it | | ----------------------------------------- | ------------------------------------------------------------------------ | | `resolve.conditions: ['gameable-source']` | Workspace packages resolve to an unbuilt `dist/` | | bare `three` aliased to `three/webgpu` | Two three singletons; a `Vector3` from one is not `instanceof` the other | | `optimizeDeps.exclude` for wasm runtimes | esbuild rewrites the emscripten glue and the module never instantiates | | `.wasm` never inlined, `application/wasm` | `WebAssembly.instantiateStreaming` refuses the response | | `import.meta.env.GAMEABLE_MODE` | The app cannot tell which sandbox to build | | `dist/guest/**` | The shipped build has no game module | ## When to use Any Vite app that embeds the engine. The templates and examples already include it. ## Install ```sh npm install -D gameable ``` Inside this repository the package is a workspace member and needs no install. It is also the one package whose `dist/` has to exist before anything else can run — a `vite.config.ts` is loaded by Node, which has no `gameable-source` condition — so its `prepare` script builds it during `npm install`. ## Minimal example ```ts import { gameable } from 'gameable/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [gameable()] }); ``` Then, in the app: ```ts if (import.meta.env.GAMEABLE_MODE === 'wasm') { const base = new URL(import.meta.env.GAMEABLE_GUEST_URL, location.href); sandbox = await createSandbox({ mode: 'wasm', guestModuleUrl: base.href, getCoreModule, host }); } else { sandbox = await createSandbox({ mode: 'direct', game: (await import('./game')).default, host }); } ``` ## API - `gameable(options?)` — the plugin. Add it to `plugins`. - `options.mode` — force `'direct'` or `'wasm'`. The default derivation is: this option, then Vite's own `--mode` when it is `direct` or `wasm`, then `GAMEABLE_MODE` in the environment, then `GAMEABLE_WASM=1`, then `wasm` for `vite build` and `direct` for `vite dev`. - `options.guestDir` — where `jco transpile` wrote the guest, relative to the Vite root. Default `build/guest`. - `options.guestBase` — the path it is served from. Default `guest/`, so the entry is `guest/game.js`. - `options.three` / `options.source` — set either to `false` to opt out of the alias or the `gameable-source` resolve condition (`development` is the old name of `source`). - `options.exclude` — extra packages kept out of the dependency pre-bundle. - `resolveGameableMode(options, env)` / `resolveGameableConfig(options, env)` — the pure functions behind the plugin, so the behaviour is testable without a server. - `ALWAYS_EXCLUDED`, `NEVER_INLINED` — the built-in lists. - `import.meta.env.GAMEABLE_MODE` and `import.meta.env.GAMEABLE_GUEST_URL` — what the plugin defines for the app. Declare them in a `vite-env.d.ts`. ## Gotchas - **`vite.config.ts` is loaded by Node, not by Vite's own resolver.** The `gameable-source` condition therefore does not apply to the config file itself, which is why this package ships a `dist/` built during `npm install`. A config that imports other workspace packages has the same problem. - **Jolt's wasm needs a URL you control.** Import it as `import wasmUrl from 'jolt-physics/jolt-physics.wasm.wasm?url'` and hand it to `physics({ wasmUrl })` and `loadJolt({ wasmUrl })`; the plugin makes sure it is copied rather than inlined, but it cannot guess where you want it. - **Building in `wasm` mode without a guest is a warning, not an error.** The bundle succeeds and the page fails at runtime with a 404. Run `npm run build:guest` first, or build with `--mode direct`. - **The guest is served off disk in dev**, straight from `guestDir`, without passing through Vite's module graph. Rebuild it to see a change; there is no HMR for the component. # FILE: packages/voice/README.md # gameable/voice ## What Optional, host-side Voxy capture and Parlay transcription lifecycle. Nothing accesses the microphone until `start()`. ## When to use Hands-free interviews with VAD and interruption. Keep typed input available when permission or transcription fails. ## Install ```sh npm install --save-exact gameable @gameable/voxy ``` ## Minimal example ```ts import { voice } from 'gameable/voice'; const microphone = voice({ playbackEchoGuardMs: 700, // Speaker-safe mode; explicit UI interruption. async createCapture() { const { VoxyCore } = await import('@gameable/voxy/core'); return new VoxyCore({ sampleRate: 16000 }); }, async transcribe(pcm, signal) { const response = await fetch('/transcribe', { method: 'POST', body: pcm, signal }); if (!response.ok) throw new Error('Transcription unavailable'); return ((await response.json()) as { text: string }).text; }, onText: console.log, onInterrupt: () => console.log('stop NPC speech'), onStatus: console.log, onError: console.error, }); // Start VAD when the player enters the game; route audio only during interviews. microphone.setTranscribing(false); await microphone.start(); microphone.setTranscribing(true); // Interview starts; requires fresh speech. microphone.setTranscribing(false); // Interview ends; VAD stays warm. // On mute or leaving the game: microphone.stop(); ``` ## API `voice(options)` returns an engine module with `start`, `stop`, `setTranscribing`, `speaking` and `dispose`. `setTranscribing` cancels pending transcription and discards speech already underway, including when called with `true` to switch conversation targets. Disabling transcription keeps capture/VAD alive without uploading utterances or interrupting playback. Injecting the capture and transcription ports makes recorded tests independent of devices. `wavToPcm16` validates complete mono PCM16 WAV utterances at 16 kHz and returns raw PCM bytes. The server-only conversation relay accepts those bytes at `/transcribe`. ## Gotchas Serve Voxy's exported model and worklet assets from your application. Inform Voxy when NPC playback starts/stops using `speaking`. With `playbackEchoGuardMs`, speech during playback and its echo tail is discarded, including an utterance that starts inside the guard and ends afterward. Provide an explicit interrupt button that stops playback; the player can speak after the tail expires. Omitting this option permits hands-free barge-in, appropriate for headphones. Browser echo cancellation alone cannot reliably distinguish speaker reverb from a player. Utterances are bounded to 30 seconds; disabling transcription or stopping capture cancels pending results. Credentials belong in a server relay. Games without this module have no Voxy dependency. # FILE: packages/wasm-host/README.md # gameable/host ## What The host half of the wasm game-logic boundary. Loads the `jco transpile`d component, supplies the `gameable:engine/*` host imports and a minimal WASI, encodes `frame-input`, and applies `frame-output` to an `EngineAdapter`. It also provides the `direct` sandbox, which runs the very same SDK guest runtime in the host's own realm with no build step — that is what `npm run dev` uses, and a parity test hashes both modes to keep them honest. ## When to use You are embedding a compiled game module, or you want a sandbox in a test or a dev server. Engine code implements `EngineAdapter`; tests use `NullEngineAdapter`. Three subpath entry points sit beside the index, each kept apart so a page or a process loads only what it needs: - **`gameable/host/features`** — a page booting a game that declares `features` in `defineGame` (ADR 0017). Every template uses it. - **`gameable/host/characters`** — the character bridge itself; the `characters` feature loads it for you, so import it by hand only outside the feature loader. - **`gameable/host/server`** — running a game as the authority of a room, on a headless engine with no renderer (ADR 0018). It imports no three.js. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { readFile } from 'node:fs/promises'; import { createSandbox, applyOutput, NullEngineAdapter, createInputEncoder } from 'gameable/host'; const adapter = NullEngineAdapter(); const encoder = createInputEncoder(4096); const sandbox = await createSandbox({ mode: 'wasm', guestModuleUrl: new URL('./build/guest/game.js', import.meta.url).href, getCoreModule: (p) => readFile(new URL(p, guestDir)).then((b) => WebAssembly.compile(b)), host, // your HostApi: log, seed, nowMs, raycast, resolveId, describe, ... }); sandbox.init({ seed: 0x5eed1234n, fixedHz: 60, viewportWidth: 1920, viewportHeight: 1080, devMode: true, }); for (let frame = 0; frame < 600; frame += 1) { const input = encoder.encode({ frame, dt: 1 / 60, elapsed: frame / 60, inputState, bodies, bodyCount, }); applyOutput(adapter, sandbox.tick(input)); if (sandbox.dead) break; // a trap poisons the instance; rebuild it } ``` The same code with `{ mode: 'direct', game, host }` runs the game's TypeScript straight from source. ### A page whose game declares features `clientFeatures(overrides?)` is the page's feature table: each entry is a dynamic `import()`, so a feature the game did not declare is never downloaded. Page-owned options ride in the overrides. ```ts import { bindFeatures, createEngine, resolveFeatures } from 'gameable/core'; import { featuresOf } from 'gameable'; import type { CharacterBridge } from 'gameable/host/characters'; import { clientFeatures } from 'gameable/host/features'; const loaded = await resolveFeatures( featuresOf(definition), clientFeatures({ characters: { headOffset: [0, 0.78, 0] } }), ); const engine = await createEngine({ canvas, modules: [...modules, ...loaded.flatMap((f) => f.modules)], }); const bound = await bindFeatures(loaded, engine); const characters = bound.get('characters') as CharacterBridge | undefined; ``` ### A room's authority on a server The server entry gives a headless engine an adapter that keeps a world record instead of a scene, the `HostApi` its sandbox imports, and the game module that ticks it. ```ts import { createHeadlessEngine } from 'gameable/core/headless'; import { physics } from 'gameable/physics'; import { createDirectSandbox, createGameSlot, createServerAdapter, createServerHost, createServerLoop, } from 'gameable/host/server'; const slot = createGameSlot(); const engine = await createHeadlessEngine({ manifest, modules: [physics(), slot.module] }); const adapter = createServerAdapter(engine.get('physics')); const host = createServerHost(engine.get('physics'), engine.assets, adapter, { seed: 7 }); const sandbox = createDirectSandbox({ mode: 'direct', game, host }); await slot.attach(createServerLoop(engine, sandbox, adapter, inputs, { seed: 7 }), engine.ctx); engine.start(); ``` ## API - **`createSandbox(options)`** — `{ mode: 'direct', game, host }` or `{ mode: 'wasm', guestModuleUrl | instantiate, getCoreModule, host, wasi? }`. Returns a `Sandbox`: `init`, `tick`, `shutdown`, `snapshot`, `restore`, `dead`, `error`. `createDirectSandbox` is the synchronous direct-mode form. - **`EngineAdapter`** — one method per command, plus `setCamera`, `setHud` and `applyTransforms(f32, count)`. `NullEngineAdapter()` records calls instead. - **`createEngineAdapter(engine, { modules, characters? })`** — the real adapter, over an `gameable/core` `Engine`. It owns the entity-to-`Object3D` mapping and the previous/current transform pairs the renderer interpolates between, resolves assets to splats and glTF scenes, and draws a placeholder mesh the shape of an entity's physics body until a model exists. Host modules are reached through `engine.get(...)`, so this package has no runtime dependency on `gameable/physics`, `audio`, `input` or `splat`; leave one out and the matching commands are ignored with one warning. `adapter.update(dt, alpha)` is one rendered frame: interpolation, then the character bridge. - **`createCharacterBridge(options)`**, from the separate entry point **`gameable/host/characters`** — turns the six `spawn-character` commands into a character per entity: a skinned glTF, a Gameable studio export (`aosrig-splat`), or a GNM splat head, each with an `Animator`. It is not exported from the index because it is the one file that imports `gameable/character`, `gameable/animation` and `gameable/splat` at runtime; the adapter names only the `CharacterBridge` type, so a game that never imports this module ships none of them. Options: `engine`, `renderer`, `scene`, `capacityPerCharacter`, `headOffset`, `headHeight`, `decoders`, `extras` (an export's optional parts: `mouth`, `corrective`, `sharedClips`, `keepSharedFiles`), `fetch`, `loadGltf`, `shadows`, `speech`, `onLoadFailed`, `warn`, `log`. The bridge also has `upgrade(entity, src, { fadeMs })` (a fuller copy of an exported character swapped in), `shadows` and `entryOf(entity)` (with `mouth`, `corrective` and `hiddenPoints` for an export). Degrades to placeholders with one warning on the WebGL fallback, and never throws. - **`clientFeatures(overrides?)`**, from **`gameable/host/features`** — the browser's `FeatureTable` for `resolveFeatures`. Today it has one entry, `characters`, which loads the bridge and binds it to the booted engine; on a headless engine its `bind` throws a `FeatureError`. `overrides.characters` is any bridge option except `engine`, `renderer` and `scene`. - **`gameable/host/server`** — `createServerAdapter(physics, options?)` (an `EngineAdapter` over a `WorldRecord` and a `BodyTable`, no scene), `createServerHost(physics, assets, adapter, options?)` (the authority's `HostApi`), `createServerLoop(engine, sandbox, adapter, inputs, options?)` (the `game` module on a headless engine; `net.role` is always `authority`), plus `TransientCommands`, `WorldRecord` (with its `EntityTable` base), the snapshot types, and the root's three-free `createDirectSandbox`, `createSandbox`, `createGameSlot` and `applyOutput`, so a server imports nothing else from this package. - **`createEngineHost(engine, adapter, options?)`** — the browser `HostApi`: `raycast`, `raycastBatch` and `overlapSphere` over the physics module, plus the manifest lookups. `filter.excludeEntity` is honoured by re-casting past the unwanted hit. - **`createHostLoop(engine, sandbox, adapter, options?)`** — the `game` `EngineModule`, at order **-50**: after input, before physics, so the commands a tick emits are simulated by the step that follows rather than the next one. One fixed step is one guest tick: commit transforms, read input and contacts, encode, `tick`, `applyOutput`. The post-step body rows arrive separately, on the `physics:stepped` event, and go straight onto the entities they drive through `adapter.applyBodyRows` — no boundary crossing. Its `update` hands the interpolation factor to the adapter. A dead sandbox stops the loop and raises an overlay. - **`createGameSlot(order?)`** — books the game module's place in the module order, because `createEngine` wants its list before the engine exists and the game module wants the engine. `slot.attach(loop, engine.ctx)` fills it in. - **`createDomHud(options?)`** — the default HUD renderer: `{ text, bars, crosshair, message }` into an overlay `div`, diffed, with no DOM work on an unchanged frame. - **`applyOutput(adapter, out)` / `applyCommand(adapter, command)`** — dispatch a `frame-output`. The command switch is exhaustive over the WIT variant. - **`createInputEncoder(maxBodies)`** — builds `frame-input` from engine state with pooled objects. `frame` becomes a `BigInt` here; everything else is written into preallocated storage. - **`hostBindings(host)`** — `HostApi` to the jco import object, with the unversioned keys jco expects. - **`minimalWasi(options)`** — the 18 `wasi:*` interfaces a componentize-qjs guest imports, with a real stderr stream so guest traps are visible. - **`quantizeInput(input)`** — rounds the `f32` fields of a `frame-input`. Direct mode applies it automatically. ## Gotchas - **Import-object keys are unversioned.** The guest imports `'gameable:engine/env@0.2.0'`; the host supplies `imports['gameable:engine/env']`. There is no `--map`. - **`u64` is a `bigint` on the host and a `number` in the guest.** `frame` and `seed` must be `BigInt`s on the way in; `env.seed()` must return a `bigint`. - **A guest trap permanently poisons the component instance.** Every later call fails with `cannot enter component instance`, so the sandbox latches `dead` and the host must build a new one rather than retry. - **`direct` and `wasm` must produce identical hashes**; the parity test in `tests/boundary/` enforces it. That is why direct mode rounds its floats. - **Host imports are synchronous only for `physics-query`.** Everything else is a command in `frame-output`. - **`createEngineAdapter` needs the booted engine, and `createEngine` needs the module list.** Use `createGameSlot()` to break the cycle; registering a module after `initAll` is refused. - **The character bridge is a subpath import, and that is deliberate.** Reaching it as `gameable/host/characters` is what keeps `onnxruntime-web` out of a game that has no characters. Import it dynamically as well and it lands in its own chunk. - **Input edges are consumed per fixed step, not per rendered frame.** The host loop reads `pressed` / `released` after `gameable/input`'s own `fixedUpdate` (order `-100`) has published them; if the input service came from somewhere that is not a registered module, the loop calls `consume()` itself. - **Placeholder meshes are lit.** They emit a quarter of their albedo so a scene with no lights still shows something, but add a light before deciding the colours are wrong. - **The server entry is three-free; the package index is not.** The index re-exports `engineAdapter.ts`, which imports `three/webgpu`. A server takes `createDirectSandbox`, `createSandbox`, `createGameSlot` and `applyOutput` from `gameable/host/server`, which carries them, and never imports `gameable/host` itself. Take the engine from `gameable/core/headless`, never from the `gameable/core` root. - **The world record only marks real changes.** A command that sets a field to the value it already holds (a `set-anim` sent every step) leaves `serial` where it was. Material parameters, expressions, look-at and clip weights are kept on the record's `visual` (so a late joiner's snapshot carries them), not in `adapter.transient`, which holds only lines, sounds, the listener and preloads. `world.spawned` and `world.despawned` list this tick's arrivals and departures and are emptied by `beginTick()`. - **Characters need the bridge.** Without `createCharacterBridge`, `spawn-character` and the rest warn once each and do nothing. Capsules stand in. - **The boundary tests are gated.** They build a real component, so the root `npm test` skips them. Run them with: ```sh GAMEABLE_BOUNDARY=1 npx vitest run -c tests/boundary/vitest.config.ts ``` # FILE: docs/recipes/index.md # Recipes A recipe answers exactly one question and is completable by editing **at most two files**. Every one has the same five headings — Goal, Files you will edit, Steps, Verify, See also — so you can skim straight to the part you need. ## Getting started The path from nothing to a game you are editing. - [Scaffold a new game](./scaffold-a-new-game.md) — `npm create gameable`, a playable game in its own directory with nothing to configure. - [Create an engine around a canvas](./create-an-engine.md) — boot `gameable/core` yourself, for a custom host rather than a template. - [Debug with doctor](./debug-with-doctor.md) — take `gameable doctor` from red to green, one check at a time. - [Use the placeholder assets](./use-the-placeholder-assets.md) — render the CC0 arena and its spawn points before you have content of your own. ## Game logic Inside the guest: systems, input, the HUD, and the component that ships them. - [Write a game system](./write-a-game-system.md) — one more function per fixed step, allocating nothing. - [Read player input](./read-player-input.md) — named actions, so no key code ever appears in game code. - [Add a HUD element](./add-a-hud-element.md) — a value on screen that only crosses the boundary on the frames it changed. - [Build the wasm guest](./build-the-wasm-guest.md) — the shipping path: `jco componentize`, `jco transpile`, and what each step is for. - [Write a guest in Rust](./write-a-guest-in-rust.md) — the same WIT world from a Rust crate; the host cannot tell the difference. ## First-person Recipes that build on `templates/fps`. - [Add a weapon](./add-a-weapon.md) — a second fire mode with its own ammunition, rate and sound. - [Add an enemy](./add-an-enemy.md) — a heavier variant sharing the existing chase-and-attack behaviour. - [Change the level](./change-the-level.md) — your own splat environment, your own collider, your own spawns. ## Third-person Recipes that build on `templates/third-person`. - [Add an interactable](./add-an-interactable.md) — a lever the hero walks up to and presses `E` at, declared with a tag, with a HUD prompt when it is in reach. - [Add NPC dialogue](./add-npc-dialogue.md) — a third person with a script of their own, in a JSON file and a prefab, without touching the dialogue system. - [Tune the follow camera](./tune-the-follow-camera.md) — boom length, shoulder height, pitch range, an over-the-shoulder offset, and who owns the collision. - [Add a locomotion state](./add-a-locomotion-state.md) — a crouch on `Ctrl` that halves walk speed, reported to the animator the way idle, walk and run are. - [Play with friends](./play-with-friends.md) — one line in `src/game.ts`, a room server beside the dev server, two tabs in one room. - [Send a message](./send-a-message.md) — `V` waves: a message declared once, read on the room's authority and answered to everyone. - [Add a lobby](./add-a-lobby.md) — nothing starts until every connected player has pressed `R`: the ready set and the start rule. - [Add a vote](./add-a-vote.md) — an emergency meeting in the mystery kit: `M` opens the vote at once, once a round per player. - [Add a night cycle](./add-a-night-cycle.md) — a day/night clock in the rules, `day` or `night` on the room list, and chests that stay shut in the dark. ## Hangout Recipes that build on `templates/hangout`. - [Park another car](./park-another-car.md) — one more spot on the street, and a third car anyone can drive. ## Steal Recipes that build on `templates/steal`. - [Save player progress](./save-player-progress.md) — a daily bonus kept in the player's document: read at join, saved with `ctx.data.save`. ## Brawl Recipes that build on `templates/brawl`. - [Add a kick](./add-a-kick.md) — a fourth move on `I`, checked on the authority like the punch: farther, harder, slower. ## Collect Recipes that build on player documents, as `templates/collect` does. - [Trade with another player](./trade-with-another-player.md) — an offer and an accept that become one all-or-nothing exchange of coins for a gem. ## World and physics - [Load a splat environment](./load-a-splat-environment.md) — a gaussian-splat capture as the world, by id, drawn in the right order. - [Add a physics body](./add-a-physics-body.md) — a crate that falls, lands and reports its collisions. - [Play a sound](./play-a-sound.md) — positional audio that falls off with distance and tells you when it ended. ## Characters and animation - [Turn on a feature](./turn-on-a-feature.md) — declare `characters` in the game definition and the page loads the rig stack only because you did. - [Use the sample character](./use-the-sample-character.md) — the `aosrig_v0` skinned body in place of a capsule, idling, walking and running from the state the game already publishes. - [Load a character](./load-a-character.md) — a splat avatar in the scene, by id, with a graceful answer on a machine without WebGPU. - [Load a character from the Asset Manager](./load-a-character-from-asset-manager.md) — the same, sourced from AAM, without a URL ever being written down. - [Play an animation on a character](./play-an-animation.md) — idle, walk and a one-shot wave, driven from the character state game logic already produces. - [Preview a rig without decoders](./preview-a-rig-without-decoders.md) — a freshly baked rig on screen as one gaussian per vertex, months before anyone has trained a decoder for it. - [Give an NPC a face](./give-an-npc-a-face.md) — wire the guest's character commands to a real splat head, so dialogue expressions and look-at are drawn rather than recorded. ## The rules, in one line each - **Two files maximum.** A third file means two recipes, or a design problem. - **Never `packages/`.** A recipe that needs an engine change is a missing feature; file it instead of documenting it. - **Assets by id.** Recipes add manifest entries; they never hardcode a URL. - **No allocation in systems.** Recipe code is copied verbatim, by humans and by language models. Preallocate. - **Runnable code only.** Every snippet is checked against the engine's real exports. ## See also - [Concepts](../concepts/index.md) — the model a recipe assumes you have - [Build your first FPS](../start/02-first-fps.md) — the long-form version - [Troubleshooting](../troubleshooting.md) — when a recipe does not do what it says # 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 # FILE: docs/recipes/add-a-locomotion-state.md # Add a locomotion state ## Goal Play authored rise, fall and landing clips using the shared third-person controller. The character bundle must already contain the six named clips below. Ground contact comes from physics, so reaching the jump apex cannot trigger a landing animation. ## Files you will edit - `src/systems/locomotion.ts` - `src/game.ts` ## Steps 1. **Select the clips.** Replace the existing controller construction in `src/systems/locomotion.ts`, keeping its reset/update exports and dialogue gate: ```ts const controller = createThirdPersonController({ idle: 'idle', walk: 'walk', run: 'run', rise: 'jump-rise', fall: 'jump-fall', land: 'jump-land', }); ``` Names refer to clips in the character's loaded bundle, not asset paths. Configure jump clips as non-looping in the bundle. Explicit clip weights select the performance; a `character.setState` label alone does not select a clip. 2. **Tune launch and landing.** Add these values to `rules` in `src/game.ts`: ```ts jumpSpeed: 6, jumpLockFrames: 3, landingSeconds: 0.18, acceleration: 10, deceleration: 14, ``` Gravity remains a world/host physics setting. Set both consistently when changing it. The host drives the capsule trajectory; prepared clips should have stationary horizontal root motion. The controller preallocates its weights and never adds frame allocations. 3. **Understand the transitions.** The controller reads `ctx.physics.isGrounded(hero)` from the preceding physics step. Launch selects rise, negative vertical speed selects fall, and walkable ground contact selects landing for `landingSeconds`. Jumping again during landing starts rise immediately. Without a clip mapping the controller still sends semantic states and uses the default locomotion blend. ## Verify ```sh npm test -w templates/third-person npm run dev -w templates/third-person ``` Jump twice, walk off a ledge, and press jump at the apex. The apex must remain airborne, the second grounded jump must replay, and walking/running must resume after landing. Check both direct and compiled WASM builds after changing packages. ## See also - [Build your first adventure](../start/03-first-adventure.md) - [Tune the follow camera](./tune-the-follow-camera.md) - [Play an animation on a character](./play-an-animation.md) - [Animation](../concepts/animation.md) # FILE: docs/recipes/add-a-physics-body.md # Add a physics body ## Goal A crate that falls, lands on the level geometry, and can be shoved by shooting it. When you are done, `npm run dev` shows a box that obeys gravity and reports its collisions to your game code. ## Files you will edit - `src/prefabs.ts` - `src/game.ts` ## Steps 1. Declare the prefab. `prefab()` is pure, so it is safe at module scope — the Wizer snapshot captures nothing. ```ts // src/prefabs.ts import { prefab } from 'gameable'; export const Crate = prefab({ name: 'crate', asset: 'crate', // a manifest string id, never a URL body: { shape: 'box', dims: [0.5, 0.5, 0.5], // half extents, metres kind: 'dynamic', mass: 25, friction: 0.6, restitution: 0.1, layer: { debris: true }, mask: { defaultLayer: true, staticGeometry: true, player: true, projectile: true }, flags: { reportContacts: true }, }, }); ``` `layer` says what the crate **is**; `mask` says what it collides with. Both sides must agree — a body whose `mask` omits `debris` will pass straight through the crate however the crate is masked. `reportContacts` is what puts this crate's collisions in `frame-input.contacts`. It is **off by default**, and worth leaving off for scenery: a body that reports nothing costs nothing, and a level full of crates that all report is a contact stream nobody reads. 2. Spawn one in `init`. Preallocate the spawn position: `init` runs once, but the same habit is what keeps systems allocation-free. ```ts // src/game.ts import { defineGame, physics, spawn, Transform, type GameContext } from 'gameable'; import { Crate } from './prefabs'; const crateSpawn = { x: 0, y: 4, z: -6 }; let crate = 0; export default defineGame({ assets: ['arena', 'crate'], init() { crate = spawn(Crate, crateSpawn); }, systems: [shoveCrate], }); ``` 3. Push it with a raycast. A hitscan shot is one synchronous query and one deferred impulse command; neither allocates in the steady state. ```ts // src/game.ts const SHOVE = 400; // newton-seconds function shoveCrate(ctx: GameContext): void { if (!ctx.input.pressed('Mouse0')) return; const hit = physics.raycast( ctx.camera.position, ctx.camera.forward, 100, undefined, ctx.player, ); if (hit === null || hit.entity !== crate) return; physics.applyImpulse( hit.entity, ctx.camera.forward.x * SHOVE, 0, ctx.camera.forward.z * SHOVE, ); } ``` 4. Read the result. The host writes every non-static body's pose into the stride-15 body buffer each fixed step, and the SDK unpacks it into `Transform`, so the crate's position is already there. Read it freely; to _move_ the crate, send a command (`physics.teleport`) rather than writing `Transform.y[crate]`, because the body is the authority and the host draws it from the body's own row: ```ts // src/game.ts — inside a system if (Transform.y[crate] < -20) physics.teleport(crate, crateSpawn.x, crateSpawn.y, crateSpawn.z); ``` ## Verify ```sh npm run dev ``` The crate falls from four metres, lands on the floor and stops within about a second. Shooting it slides it away from you; it never sinks through the floor and never comes to rest below `y = 0`. In a test, assert the landing: ```ts expect(Transform.y[crate]).toBeGreaterThan(0.4); expect(Transform.y[crate]).toBeLessThan(0.6); ``` ## See also - [Physics](../concepts/physics.md) - [Modules](../concepts/modules.md) - `packages/physics-jolt/README.md` — gameable/physics # 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/add-an-interactable.md # Add an interactable ## Goal A new kind of thing in the world that the hero can walk up to and press `E` at: a lever that unlocks the door without a key. When it is in reach the HUD says `E: pull lever`, and pulling it opens the door. ## Files you will edit - `src/prefabs.ts` - `src/systems/interact.ts` ## Steps 1. **Declare what it is.** Tags are the vocabulary: `Interactable` means "`E` does something here" and a second tag says what. Add both the tag and the prefab to `src/prefabs.ts`: ```ts /** Tag: a lever. Pull it. */ export const Lever: Record = {}; /** A lever: a thin post you pull once. */ export const LeverPrefab = prefab({ name: 'lever', body: { shape: 'box', dims: [0.12, 0.6, 0.12], kind: 'fixed', layer: { defaultLayer: true }, mask: { player: true, character: true }, }, components: [Interactable, Lever], }); /** Lever brass. */ export const LEVER_COLOUR: readonly [number, number, number] = [0.7, 0.55, 0.2]; ``` 2. **Give the kind a number.** The interact system reads one integer per candidate rather than asking bitecs four questions a frame, so every kind has a constant. Add it next to the others in `src/prefabs.ts`: ```ts /** A lever. */ export const KIND_LEVER = 5; ``` 3. **Classify it.** In `src/systems/interact.ts`, add one branch to `classifyInteractables`, which runs once during `init`: ```ts interactables.kind[e] = hasComponent(ctx.world, e, Lever) ? KIND_LEVER : hasComponent(ctx.world, e, Chest) ? KIND_CHEST : /* …the existing chain… */ KIND_NONE; ``` 4. **Give it a prompt.** Still in `src/systems/interact.ts`, one line in `promptFor`: ```ts if (kind === KIND_LEVER) return used ? '' : 'E: pull lever'; ``` 5. **Say what `E` does.** One branch in `activate`, which already has the door entity to hand: ```ts if (kind === KIND_LEVER) { interactables.used[entity] = 1; ctx.audio.play('sfx.door', { entity, volume: 0.8 }); // The door's own `used` lane is what `slideDoor` watches. if (interactState.door !== 0) interactables.used[interactState.door] = 1; notify(ctx, 'Somewhere, something heavy moves.'); return; } ``` 6. **Put one in the level.** Add a point to `src/arena.ts` and an entry to the `spawns` list in `src/game.ts` — the same two lines every other prop uses. Paint it in `init` with `tint(entity, LEVER_COLOUR)`. ## Verify ```sh npm test -w templates/third-person npm run dev -w templates/third-person ``` Walk up to the lever. The HUD's top-left rows gain `prompt E: pull lever`, and pressing `E` starts the door sliding with no key in your pocket. Add the case to `tests/game.test.ts` the way `refuses the door without the key` is written — place the hero, assert the prompt, tap `E`, assert `questState.escaped` after a hundred steps. ## See also - [Build your first adventure](../start/03-first-adventure.md) — where the interact cone and the tags come from - [Add NPC dialogue](./add-npc-dialogue.md) — the other thing `E` can start - [ECS and game code](../concepts/ecs.md) — what a tag is in this ECS - [Write a game system](./write-a-game-system.md) — if the behaviour outgrows one branch # FILE: docs/recipes/add-npc-dialogue.md # Add NPC dialogue ## Goal A third person in the arena with a script of their own: two lines and a yes/no question, their own name above the box, and their own expression per line. No change to the dialogue system. ## Files you will edit - `src/dialogue.json` - `src/prefabs.ts` ## Steps 1. **Write the script.** `src/dialogue.json` is a record of conversations keyed by name. Each has a `speaker`, a list of `lines`, and an optional `question` with a `yes` and a `no`. `face` names a preset in `FACES`, in `src/systems/dialogue.ts` — `neutral` or `smile` ship with the template. ```json { "smith": { "speaker": "Smith", "lines": [ { "text": "You will want the key before you try that door.", "face": "neutral" }, { "text": "I would fetch it myself, but I am busy.", "face": "smile" } ], "question": { "text": "Want me to point at the chest?", "face": "smile", "yes": { "text": "That one. The obvious one.", "face": "smile" }, "no": { "text": "Brave. Good luck.", "face": "neutral" } } } } ``` 2. **Tag an NPC with it.** Which script an NPC runs is a tag, so the level stays declarative and nothing is patched up after the spawn. In `src/prefabs.ts`: ```ts /** Tag: this NPC runs the `smith` conversation in `src/dialogue.json`. */ export const Smith: Record = {}; /** The smith: a third person to talk to. */ export const SmithPrefab = prefab({ name: 'smith', body: NPC_BODY, character: 'char.guide', components: [Interactable, Npc, Smith], }); ``` 3. **Point `scriptOf` at the tag.** One line in `src/systems/dialogue.ts` chooses the key: ```ts export function scriptOf(ctx: GameContext, npc: number): string { if (hasComponent(ctx.world, npc, Smith)) return 'smith'; return hasComponent(ctx.world, npc, Guide) ? 'guide' : 'wanderer'; } ``` 4. **Put them in the level.** A point in `src/arena.ts` and an entry in the `spawns` list in `src/game.ts`, lifted by `NPC_CENTRE` like the others. That is the whole recipe. Everything else is already done for you: `character.setExpression` and `character.lookAt` go out for every line, and `dialogueState.active` freezes the hero while anyone is talking. Both are the dialogue system's job, not the script's. Whether any of it is _drawn_ is the host's decision, not the script's. Out of the box the NPC is a capsule and the expression and the gaze are recorded on `adapter.animationOf(entity)` with one warning. Give the template a character bridge — [Give an NPC a face](./give-an-npc-a-face.md) — and the same commands move a real splat head, with nothing in `src/` changing. ## Verify ```sh npm test -w templates/third-person npm run dev -w templates/third-person ``` Walk up to the smith. The HUD shows `speaker Smith` and the first line, `E` advances, and the question offers `1 yes` and `2 no`. In the headless tests, `scripts.smith.lines` is readable straight out of the import and `dialogueState.script` is `'smith'` once the conversation starts. ## See also - [Build your first adventure](../start/03-first-adventure.md) — the whole loop - [Add an interactable](./add-an-interactable.md) — the other thing `E` can start - [Add a locomotion state](./add-a-locomotion-state.md) — what the hero does meanwhile - [Characters](../concepts/characters.md) — what expressions and gaze will drive # FILE: docs/recipes/build-the-wasm-guest.md # Build the wasm guest ## Goal Your game's TypeScript becomes a WebAssembly component that the host can instantiate — the shipping path, rather than the `direct` mode `npm run dev` uses. ## Files you will edit - `scripts/build.mjs` - `package.json` ## Steps 1. Write the build script. Three jco steps, every path absolute and forward-slashed, because Windows paths break shell-string pipelines. `fixtures/tiny-game/scripts/build.mjs` is the reference; the shape is: ```js // scripts/build.mjs import { spawnSync } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; const GAME = fileURLToPath(new URL('../', import.meta.url)) .replaceAll('\\', '/') .replace(/\/$/, ''); const BUILD = `${GAME}/build`; const JCO = `${GAME}/node_modules/@bytecodealliance/jco/dist/jco.js`; // The authored WIT package. `gameable build` resolves this for you; a // hand-rolled script points at the copy that ships with the engine. const WIT = `${GAME}/wit`; const jco = (args) => { const r = spawnSync(process.execPath, [JCO, ...args], { stdio: 'inherit' }); if (r.status !== 0) throw new Error(`jco ${args[0]} failed`); }; mkdirSync(BUILD, { recursive: true }); // jco componentize compiles exactly one module, and that module must import // the versioned gameable:engine specifiers. Generate it; never hand-write it. writeFileSync( `${BUILD}/entry.ts`, [ `import { createGuestExports } from 'gameable/sdk/wit/entry';`, `import definition from '../src/game';`, ``, `export const game = createGuestExports(definition);`, ].join('\n'), 'utf8', ); // rolldown runs with platform "neutral", so the workspace `gameable-source` // export condition never applies. Alias the SDK onto its sources. writeFileSync( `${BUILD}/rolldown.config.mjs`, `export default { resolve: { alias: { 'gameable': '${GAME}/node_modules/gameable/sdk/src/index.ts' } } };`, 'utf8', ); 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`, ]); jco([ 'transpile', `${BUILD}/game.wasm`, '--instantiation', 'async', '--no-nodejs-compat', '--name', 'game', '-o', `${BUILD}/guest`, ]); ``` 2. Wire it up and ignore the output. ```diff // package.json "scripts": { + "build:guest": "node scripts/build.mjs" } ``` Add `build/` to `.gitignore`. It is a 2 MiB component plus nine core wasm files, all reproducible. ## Verify ```sh npm run build:guest ``` `jco componentize` prints red `UNRESOLVED_IMPORT` warnings for the `gameable:engine/*` specifiers — that is expected noise, not a failure; componentize resolves them itself, after the bundle. It then prints `OK Successfully written .../build/game.wasm`, and `jco transpile` lists nine `.core*.wasm` files plus `game.js`. Load it with `createSandbox({ mode: 'wasm', guestModuleUrl, getCoreModule, host })` and the first `tick` should return the same `frame-output` hash as `mode: 'direct'`. ## See also - [The wasm boundary](../concepts/wasm-boundary.md) - [Write a game system](./write-a-game-system.md) - `packages/wasm-host/README.md` — gameable/host - [Troubleshooting](../troubleshooting.md) # 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/create-an-engine.md # Create an engine around a canvas ## Goal A page that boots `gameable/core`, renders a scene at 60 Hz on WebGPU (or the WebGL fallback), resizes with its canvas, and shows the F3 debug overlay in development. ## Files you will edit - `src/main.ts` - `assets.json` ## Steps 1. Declare your content. Every asset the game can reference lives here, and game code refers to it by `id` only. ```json { "version": 1, "baseUrl": "/assets/", "assets": [{ "id": "arena", "type": "gltf", "src": "arena.glb", "tags": ["world"] }] } ``` 2. Boot the engine in `src/main.ts`. `createEngine` is async because the renderer has to acquire a `GPUDevice` before anything else can use it. ```ts import { createEngine } from 'gameable/core'; const canvas = document.querySelector('canvas'); if (!(canvas instanceof HTMLCanvasElement)) throw new Error('no on the page'); const engine = await createEngine({ canvas, manifest: '/assets/assets.json', modules: [], fixedHz: 60, renderer: { backend: 'auto', antialias: true, pixelRatioCap: 2 }, debug: true, }); console.log(engine.ctx.caps.webgpu ? 'webgpu' : 'webgl fallback'); ``` 3. Put something in the scene and load the world. `engine.graph` maps numeric entity ids onto three objects; `engine.assets` resolves ids to bytes. ```ts import type { GLTF } from 'three/addons/loaders/GLTFLoader.js'; import { DirectionalLight } from 'three/webgpu'; engine.scene.add(new DirectionalLight(0xffffff, 2)); engine.camera.position.set(0, 1.7, 4); const arena = (await engine.assets.load('arena')) as GLTF; engine.graph.spawn(1, arena.scene); ``` 4. Start the loop, and stop it again when the page goes away. ```ts engine.start(); window.addEventListener('pagehide', () => { void engine.dispose(); }); ``` ## Verify ```sh npm run dev ``` The canvas fills its container and redraws as you resize the window. Press **F3**: an overlay appears in the top-left showing the backend name, a frame rate, `p50`/`p95` frame times and the draw-call count. `engine.running` is `true`, and the console line printed in step 2 says which backend you got. ## See also - [The engine loop](../concepts/engine-loop.md) - [Engine modules](../concepts/modules.md) - [Assets and the manifest](../concepts/assets.md) - `packages/core/README.md` — gameable/core # FILE: docs/recipes/debug-with-doctor.md # Debug with doctor ## Goal `gameable doctor` goes from red to green, and you understand what each check was protecting you from. ## Files you will edit - `package.json` - `src/assets.json` ## Steps 1. Run it. The exit code is the number of failures, so it composes into scripts and CI without parsing anything. ```sh npx gameable doctor ``` ``` gameable doctor F:/games/my-fps ok node v24.14.0 (need >= 24) ok npm v11.9.0 (need >= 11) warn git lfs not installed (only needed for your own large binary assets) FAIL three 2 instances: 0.180.0, 0.186.0 Pin one version in package.json "overrides": { "three": "0.186.0" }, then `rm -rf node_modules package-lock.json && npm install`. ok assets.json 7 entries, valid FAIL asset files 1 missing: shot -> audio/shot.ogg Put the files under public/ (Vite serves that at the site root), or fix the manifest src. ok wit .../node_modules/gameable/sdk/wit ok jco v1.33.0 ok componentize-qjs v0.4.4 ok qjs binding @andreiltd/componentize-qjs-binding-win32-x64-msvc ok wit imports no reserved aos: specifiers in src/ warn webgpu cannot be probed from node; check chrome://gpu in the browser FAIL 2 failure(s), 2 warning(s) ``` Warnings never fail. `git lfs` and `webgpu` are warnings by design: LFS is only for binary content of your own, and node cannot see a GPU, so that one is a reminder rather than a result. 2. Fix each failure. Every one prints its own fix; these are the four that actually happen. **Two copies of `three`.** Two module singletons means `instanceof` checks start failing in ways that look like renderer bugs. Pin it: ```diff // package.json + "overrides": { + "three": "0.186.0" + } ``` ```sh rm -rf node_modules package-lock.json && npm install ``` **A manifest file that is not on disk.** Assets are addressed by id, and the id resolves through `src/assets.json` to a file Vite serves from `public/`. Either move the file or fix the `src`: ```diff // src/assets.json { "id": "shot", "type": "audio", "src": "audio/shot.ogg" } - { "id": "shot", "type": "audio", "src": "sfx/shot.ogg" } ``` **A missing `componentize-qjs` binding.** The native binding is an optional dependency, and `npm install --no-optional` — or a lockfile written on another platform — skips it. Reinstall without the flag, or add it back: ```sh npm install --save-optional @andreiltd/componentize-qjs-binding-win32-x64-msvc ``` **A reserved `aos:` import.** `gameable:engine/env@0.2.0` and friends exist only inside `jco componentize`; importing one from game code produces a bundle that builds and then traps at instantiation. Everything a game needs is re-exported: ```diff - import { log } from 'gameable:engine/env@0.2.0'; + import { log } from 'gameable'; ``` 3. Re-run until the exit code is zero, then wire it into CI ahead of the build: ```diff // package.json "scripts": { + "pretest": "gameable doctor" } ``` ## Verify ```sh npx gameable doctor && echo green ``` `green` prints only when every check passed. `--quiet` hides the passing ones, which is what you want in a CI log. ## See also - [Install](../start/01-install.md) - [Ship it](../start/04-deploy.md) - [Build the wasm guest](./build-the-wasm-guest.md) - `packages/cli/README.md` — gameable/cli # FILE: docs/recipes/load-a-splat-environment.md # Load a splat environment ## Goal Your game world is a gaussian splat capture instead of an empty scene: it loads by id from the manifest, appears when the game starts, and draws behind everything else in the right order. ## Files you will edit - `assets.json` - `src/game.ts` ## Steps 1. Declare the capture in `assets.json`. The id is the contract; the path is the host's business and never appears in game code. SPZ is the format to prefer — it is roughly a third the size of the equivalent PLY and decodes faster — but `.ply`, `.splat` and `.ksplat` all work, and the loader picks the decoder from the extension. ```json { "version": 1, "baseUrl": "/assets/", "assets": [ { "id": "arena", "type": "splat", "src": "worlds/arena.spz", "tags": ["world"] }, { "id": "arena-collider", "type": "gltf", "src": "worlds/arena-collider.glb", "tags": ["world"] } ] } ``` A splat has no collision — it is a cloud of translucent ellipsoids, not a surface — so ship a low-poly collider mesh next to it and give physics that. The pair is what makes a splat world walkable. 2. Put it in the scene on startup. `preload` fetches everything tagged `world`, and the splat module's service adds the object with the right draw order already set. ```ts import { defineGame } from 'gameable'; export default defineGame({ assets: 'assets.json', async init(ctx) { await ctx.assets.preload('world'); ctx.engine.get('splat').add('arena'); }, }); ``` Registering the `splat` asset type is the `splat()` module's job, and the FPS and third-person templates already list it in `createEngine({ modules })`. If you started from a bare `createEngine` call, add it: ```ts import { splat } from 'gameable/splat'; const engine = await createEngine({ canvas, manifest: 'assets.json', modules: [splat()] }); ``` 3. Place it. A capture's origin is wherever the photogrammetry decided, which is rarely your world origin, and its scale is whatever the capture's metric scale was. ```ts const world = ctx.engine.get('splat').add('arena'); world.position.set(0, -1.2, 0); world.rotation.y = Math.PI / 2; ``` Move the collider by the same transform, or the player will walk through the floor they can see. ## Verify ```sh npm run dev ``` The world is there and holds still as you look around; there is no popping or flickering as the camera turns, and the overlay (`F3`) shows one draw call for it. Then assert it headlessly: ```sh npm test ``` ```ts const asset = await loadSplat('/assets/worlds/arena.spz'); expect(asset.count).toBeGreaterThan(0); expect(asset.format).toBe('spz'); ``` If the world renders as a grey fog, the capture's covariances are in the wrong units — check it in the viewer with `npm run dev -w examples/splat-viewer` first. If it renders but flickers against a window or a water plane, that is the draw-order rule: transparent meshes must not intersect splat volumes. ## See also - [Gaussian splats](../concepts/splats.md) - [Assets and the manifest](../concepts/assets.md) - `packages/splat/README.md` — gameable/splat # FILE: docs/recipes/play-a-sound.md # Play a sound ## Goal A pickup makes a noise from where it sits in the world, at a volume that falls off with distance, and your game reacts when the sound finishes. ## Files you will edit - `assets.json` - `src/systems/pickups.ts` ## Steps 1. Declare the sound in `assets.json`. The id is the contract; the path is the host's business and never appears in game code. ```json { "version": 1, "assets": [ { "id": "arena", "type": "splat", "src": "arena.spz" }, { "id": "pickup-chime", "type": "audio", "src": "sfx/chime.ogg", "tags": ["sfx"] } ] } ``` 2. Play it from the system that handles the pickup. Passing `entity` makes the voice positional: the host tracks that entity and pans the sound as you move around it. `play` returns the guest-minted handle. ```ts import type { GameContext } from 'gameable'; // Module scope is fine for a handle: it is set in a system, not at import // time, so the Wizer snapshot does not freeze a value into the binary. let chime = 0; export function pickups(ctx: GameContext): void { for (const e of touched(ctx)) { // Positional: the host follows the entity until the sound ends. chime = ctx.audio.play('pickup-chime', { entity: e, volume: 0.8, bus: 'sfx' }); ctx.despawn(e); } } ``` 3. React when it finishes, in the same file. The host emits `sound-ended` with the handle you were given, and it arrives in the next tick's `ctx.events`. ```ts export function pickupAudio(ctx: GameContext): void { for (const ev of ctx.events) { if (ev.tag === 'sound-ended' && ev.val.sound === chime) chime = 0; } } ``` ## Verify ```sh npm run dev ``` Walk into the pickup. The chime comes from the pickup's direction — turn around and it swaps ears — and the object disappears. Then assert it headlessly: ```sh npm test ``` ```ts expect(harness.commands('play-sound')).toContainEqual( expect.objectContaining({ asset: 'pickup-chime', bus: 'sfx' }), ); ``` ## See also - [Assets and the manifest](../concepts/assets.md) - [Engine modules](../concepts/modules.md) - `packages/audio/README.md` — gameable/audio # FILE: docs/recipes/play-an-animation.md # Play an animation on a character ## Goal An NPC in your scene idles when it is standing still, walks when it moves, and waves once when the player gets close — driven entirely from the character state your game logic already produces. ## Files you will edit - `assets.json` - `src/game.ts` ## Steps 1. Ship the clips inside the rig. A `skinned` character is one GLB whose glTF animations carry `extras.aos = { speed, loop, locomotion }`; the animator builds its locomotion index from the ones tagged `locomotion` and registers every clip by name, so `assets.json` needs only the character entry: ```json { "assets": [ { "id": "char.npc", "type": "character", "src": "@aosrig/aosrig_v0.glb", "rig": { "backend": "skinned" } } ] } ``` Clips retargeted by `packages/animation/tools/retarget_locomotion.mjs` arrive as one GLB each plus a `locomotion.json`; hand that index to `createAnimator` as `locomotion` and `addClip` each GLB's clip, as the next steps do. 2. In `src/game.ts`, give the NPC an animator when it spawns. `locomotion` is the parsed `locomotion.json`; the animator sorts it once, here, not per frame. ```ts import { createAnimator } from 'gameable/animation'; const animator = createAnimator({ root: npc.rigRoot, expressionSpace: npc.bundle.expressionSpace, locomotion: assets.get('npc.locomotion'), }); ``` 3. Register the gesture clip as ADDITIVE, so the base layer's per-frame weight pass does not stop it while the envelope is running. ```ts animator.addClip('wave', assets.get('npc.wave'), { additive: true }); ``` 4. Drive it from the character state each frame. Preallocate the state object outside the system: this runs sixty times a second. ```ts const npcState = { velocity: [0, 0, 0], grounded: true, lookAt: null }; function animateNpc(dt: number): void { npcState.velocity[0] = npc.velocity.x; npcState.velocity[1] = npc.velocity.y; npcState.velocity[2] = npc.velocity.z; npcState.lookAt = playerIsNear ? playerPosition : null; animator.setState(npcState); animator.update(dt, { camera }); npc.setBodyPose(animator.bodyPose); npc.setExpression(animator.expression); } ``` 5. Fire the wave once, from whatever decides the player is close. The gesture channel is one slot: a second `play` replaces whatever was in flight. ```ts if (playerJustArrived) animator.gesture.play('wave', { durationMs: 1500 }); ``` ## Verify Run `npm run dev` and walk up to the NPC. It should be idling, blend into a walk as it moves, turn its head to follow you without the neck exceeding about 45 degrees, and wave once as you arrive. Asserting it in a test: with a velocity of `[0, 0, 0]` the animator's pose matches the idle clip, and at the locomotion index's top speed it matches the run clip, so ```ts animator.setState({ velocity: [0, 0, 0], grounded: true, lookAt: null }); animator.update(1 / 60); const standing = animator.bodyPose.bones.slice(); animator.setState({ velocity: [4, 0, 0], grounded: true, lookAt: null }); animator.update(1 / 60); expect(animator.bodyPose.bones).not.toEqual(standing); ``` ## See also - [Animation](../concepts/animation.md) - [Characters](../concepts/characters.md) - `packages/animation/README.md` — gameable/animation - [Assets and the manifest](../concepts/assets.md) # FILE: docs/recipes/read-player-input.md # Read player input ## Goal Move the player with `WASD` or the left stick, and fire with the left mouse button or the right trigger — without a key code appearing anywhere in your game code. When you are done, `input.axis2('move')` drives the character and `input.pressed('fire')` fires exactly once per click. ## Files you will edit - `src/game.ts` — declare the bindings. - `src/systems/move.ts` — read the actions. ## Steps 1. Declare the action map in `src/game.ts`. Names are yours; bindings are `KeyboardEvent.code` values, friendly aliases (`'W'`, `'Space'`, `'LMB'`) or `Gamepad*` names. ```ts import { defineGame } from 'gameable'; import { move } from './systems/move'; export default defineGame({ assets: './assets.json', world: 'arena', actions: { fire: ['LMB', 'GamepadRT'], jump: ['Space', 'GamepadA'], move: { axis2: ['A', 'D', 'S', 'W'], gamepadAxes: [0, 1] }, }, systems: [move], }); ``` 2. Read them in `src/systems/move.ts`. `axis2` returns `[x, y]` in -1..1, with `+y` forward; the tuple is reused every call, so destructure it instead of keeping the reference. ```ts import { input, type Ctx } from 'gameable'; const SPEED = 5; export function move(ctx: Ctx): void { const [x, y] = input.axis2('move'); ctx.player.velocity[0] = x * SPEED; ctx.player.velocity[2] = -y * SPEED; if (input.pressed('jump')) ctx.player.jump(); } ``` 3. Use the edge readers, not the held reader, for one-shot actions. `pressed('fire')` is true on exactly one **simulation step** per click — including a click that starts and ends between two rendered frames, and however many frames the display drew since the last step — while `down('fire')` stays true for as long as the button is held, which is what an automatic weapon wants. ```ts if (input.pressed('fire')) fireOnce(); if (input.down('fire')) holdTrigger(); ``` 4. For a mouse-look camera, ask for pointer lock and read the frame's delta. Deltas are zero unless the lock is held, so this is safe to run always. ```ts import { input, type Ctx } from 'gameable'; const SENSITIVITY = 0.0022; export function look(ctx: Ctx): void { if (input.pressed('fire') && !input.locked) input.requestPointerLock(); ctx.camera.yaw -= input.mouse.dx * SENSITIVITY; ctx.camera.pitch -= input.mouse.dy * SENSITIVITY; } ``` ## Verify ```sh npm run dev ``` Hold `W` and the player walks forward; the left stick does the same. Click and the weapon fires once per click, not once per frame. Then assert it headlessly: ```sh npm test ``` ```ts // tests/smoke.spec.ts it('fires once per click', () => { harness.mouseDown(0); harness.frame(); expect(harness.shots).toBe(1); harness.frame(); // still held, no second shot expect(harness.shots).toBe(1); }); ``` ## See also - [Engine modules](../concepts/modules.md) - [The engine loop](../concepts/engine-loop.md) - [`gameable/input` API](../api/@gameable.input.md), and `packages/input/README.md` - [Build your first FPS](../start/02-first-fps.md) # FILE: docs/recipes/scaffold-a-new-game.md # Scaffold a new game ## Goal A playable game in its own directory, wired to the engine, with your name on it and nothing to configure. ## Files you will edit - `src/game.ts` - `src/assets.json` ## Steps 1. Create it. The `--` is npm's: without it, npm eats the flags. ```sh npm create gameable my-fps -- --template fps cd my-fps npm run dev ``` That is already a playable game. `--template third-person` gives you the other scaffold; `--title "Capsule Hunt"` sets the page title and the README heading; `--no-install` and `--no-git` skip those steps; `--aam` adds the AvatarOS Asset Manager keys to `.env.example`. With no arguments at all, and an interactive terminal, it asks. In CI, or under an agent, it takes the defaults and never blocks. 2. Make it yours. Everything is in `src/game.ts`: ```diff // src/game.ts export default defineGame({ assets: './assets.json', world: 'arena', - player: { spawn: [0, 1.7, 0], speed: 5, jump: 4.5, health: 100 }, + player: { spawn: [0, 1.7, 0], speed: 8, jump: 6, health: 150 }, spawns: [ { prefab: 'enemy', at: [6, 0, -4] }, + { prefab: 'enemy', at: [0, 0, -20] }, ], rules: { win: (ctx) => ctx.count('enemy') === 0 }, systems: [weapon, enemyAI, pickups], }); ``` New content is a manifest entry, never a path in code. Drop the file into `public/` and give it an id: ```diff // src/assets.json "assets": [ + { "id": "gallery", "type": "splat", "src": "gallery.spz" }, { "id": "arena", "type": "splat", "src": "arena.spz" } ] ``` Then `world: 'gallery'`. The ids are the contract across the wasm boundary; renaming one is a breaking change. ## Verify ```sh npx gameable doctor # 0 failures npm run dev # http://localhost:5173 — walk, shoot, win ``` Vite reloads on save. If the game behaves differently under `npm run build`, that is a parity bug in the engine, not a configuration difference — the two modes share the same guest runtime and a test hashes both. ## See also - [Install](../start/01-install.md) - [Build your first FPS](../start/02-first-fps.md) - [Debug with doctor](./debug-with-doctor.md) - `packages/create-gameable/README.md` — create-gameable # FILE: docs/recipes/tune-the-follow-camera.md # Tune the follow camera ## Goal A camera that sits where you want it: a longer or shorter boom, a different pivot height, a pitch range that suits your level, and smooth body turning when the camera moves behind the character. ## Files you will edit - `src/game.ts` - `src/systems/locomotion.ts` ## Steps 1. **Know who owns what.** The guest states an intent; the host owns the arm. `ctx.camera.follow(hero, { yaw, pitch, distance, height })` writes one `camera-state` record — the orbit pivot, the direction the player is looking, the boom length — and the engine builds a spring arm from it, probes from the pivot towards where the camera wants to sit, and pulls the boom in when a wall is in the way. You never write the collision. 2. **Change the numbers.** Everything is a rule in `src/game.ts`, and the declarative `player` block has to agree with it, because the engine re-states the rig after every user system: ```ts player: { prefab: HeroPrefab, spawn: HERO_SPAWN, camera: 'thirdPerson', distance: 3, // a tighter, more claustrophobic boom height: 0.25, // above the body centre: chest height on the 1.73 m rig sensitivity: 0.0018, }, rules: { cameraDistance: 3, cameraHeight: 0.25, cameraPitch: -0.3, // resting: a little above, looking down cameraMinPitch: -1.0, // how far the camera may rise cameraMaxPitch: 0.45, // how far it may drop and look up }, ``` Keep `distance`/`height` and `cameraDistance`/`cameraHeight` the same number. They are the same boom stated twice, and `tests/game.test.ts` asserts the result. 3. **Tune body turning.** The shared SDK controller follows these rules in `src/game.ts`: ```ts idleTurnThreshold: Math.PI / 2, idleTurnRate: 4.5, moveTurnRate: 10, acceleration: 10, deceleration: 14, ``` Small stationary orbits leave the body facing alone. Once the camera passes the threshold, the body completes its turn smoothly. While moving, it faces its camera-relative travel direction. The `aosrig_v0` model faces +Z, so its initial visual yaw is pi when the camera starts on +Z. 4. **Keep the shared controller.** `src/systems/locomotion.ts` constructs `createThirdPersonController()` from the SDK. It clamps the camera look accumulator in place and reuses a follow-options object every frame. Keep `controller.reset(ctx)` in game init so restart restores the camera, facing, acceleration and jump state together. ## Verify ```sh npm test -w templates/third-person npm run dev -w templates/third-person ``` Walk the hero into a corner and hold the mouse so the camera swings into the wall: the boom shortens instead of the wall filling the screen. The headless test `rides a spring arm behind the hero` asserts `armLength` and `offset.y` straight out of `output.camera`, so a mismatched pair fails before you look at it. ## See also - [Build your first adventure](../start/03-first-adventure.md) — the template this tunes - [Add a locomotion state](./add-a-locomotion-state.md) — the other half of the same system - [Read player input](./read-player-input.md) — where the mouse delta comes from - [The engine loop](../concepts/engine-loop.md) — why the camera is interpolated, not snapped # FILE: docs/recipes/turn-on-a-feature.md # Turn on a feature ## Goal Your game declares the `characters` feature, and the page loads the character bridge (the rig stack behind `spawn-character`) only because it did. ## Files you will edit - `src/game.ts` ## Steps 1. Declare the feature in the game definition. A feature set to `true` loads with its default options; a feature that is absent or `false` is never loaded and never downloaded. ```ts import { defineGame } from 'gameable'; export default defineGame({ // Optional engine features this game opts into; the page resolves each by name. features: { characters: true }, assets: ['char.enemy'], }); ``` 2. Run the game. The page reads `features` from your definition, looks each name up in its table (`clientFeatures()` from `gameable/host/features`) and imports only those. ```sh npm run dev ``` 3. Take the line out again, reload, and watch the network tab. With `features: { characters: true }` removed the page makes no request for the `characters` module, because its loader, a dynamic `import()` inside the table, is never called. ```ts import { defineGame } from 'gameable'; export default defineGame({ assets: ['char.enemy'], }); ``` ## Verify The templates assert their declaration in `tests/game.test.ts`: ```sh npx vitest run tests/game.test.ts ``` The `declares the characters feature` test passes when `featuresOf(game)` equals `{ characters: {} }`. If you removed the line in step 3, put it back first, or that test fails. ## See also - [Features: what a game declares](../concepts/modules.md#features-what-a-game-declares) - `packages/sdk/README.md` — `featuresOf` - [Use the sample character](./use-the-sample-character.md) # FILE: docs/recipes/use-the-placeholder-assets.md # Use the placeholder assets ## Goal Your game renders the placeholder arena, puts the player and six enemies on its spawn points, and fires a sound — with no content of your own and no asset paths anywhere in game code. ## Files you will edit - `src/main.ts` - `src/game.ts` ## Steps 1. Merge the pack into the manifest in `src/main.ts`. The package ships its own entries and its own `baseUrl`, so the ids `env.arena` and `sfx.*` become available without a single path appearing in your game. ```ts import { parseManifest } from 'gameable/assets'; import { arenaSpawns, PLACEHOLDER_ASSETS_BASE, placeholderManifest } from 'gameable/placeholder'; import { createEngine } from 'gameable/core'; const manifest = parseManifest( { ...placeholderManifest, baseUrl: PLACEHOLDER_ASSETS_BASE }, { baseUrl: PLACEHOLDER_ASSETS_BASE }, ); const engine = await createEngine({ canvas: document.querySelector('canvas')!, manifest, // The spawn table is data, not an asset: hand it to the guest as config. config: { spawns: arenaSpawns }, }); await engine.assets.preload('world'); await engine.start(); ``` 2. Use the ids and the spawn points from `src/game.ts`. Spawn coordinates arrive as plain numbers through `ctx.config`, so the guest never imports the package and never sees a URL. ```ts import type { GameContext } from 'gameable'; // Set in init, not at module scope: Wizer freezes module state into the // binary, so a handle taken at import time would be the same every run. let arena = 0; export function init(ctx: GameContext): void { arena = ctx.assets.resolve('env.arena'); ctx.spawnWorld(arena); const { player, enemies } = ctx.config.spawns; ctx.spawnPlayer(player.position, player.yaw); for (const enemy of enemies) ctx.spawnEnemy(enemy.position, enemy.yaw); } export function fire(ctx: GameContext): void { // By id, never by path: the registry is the only thing that knows a URL. ctx.audio.play('sfx.shot', { bus: 'sfx', volume: 0.7 }); } ``` ## Verify ```sh npm run dev ``` You start at `[0, 0, 9]` looking down `-Z` at a 24 x 24 m checkered floor ringed by four 3 m walls, with six pillars, a crate and a ramp between you and the far wall, and six capsules standing on the perimeter. Firing plays a short crack. Then assert it headlessly: ```sh npm test ``` ```ts expect(harness.commands('spawn-world')).toContainEqual( expect.objectContaining({ asset: 'env.arena' }), ); expect(harness.commands('spawn-enemy')).toHaveLength(6); ``` ## See also - [Assets and the manifest](../concepts/assets.md) - [Play a sound](./play-a-sound.md) - `packages/assets-placeholder/README.md` — gameable/placeholder # FILE: docs/recipes/use-the-sample-character.md # Use the sample character ## Goal An entity that was a placeholder capsule is drawn as the `aosrig_v0` sample character — a skinned body with a GNM head — and idles, walks and runs from the character state your game already publishes. It works on WebGPU and on the WebGL fallback, and nothing per frame crosses the wasm boundary for it. ## Files you will edit - `src/assets.json` - `src/prefabs.ts` ## Steps 1. **Declare the rig in the manifest.** `gameable/aosrig` ships one GLB with the skeleton, the skinning and four clips (`idle`, `walk`, `run`, `wave`). A `character` entry whose `rig.backend` is `skinned` says "the `src` is that GLB". The templates resolve `@aosrig/` to the bundled URL the same way they resolve `@placeholder/`: ```json { "id": "char.hero", "type": "character", "src": "@aosrig/aosrig_v0.glb", "tags": ["character"], "rig": { "backend": "skinned" } } ``` Several prefabs can share one entry: the GLB is parsed once per URL and cloned per spawn. 2. **Name it on the prefab.** `character` makes `ctx.spawn` emit a `spawn-character` command beside the entity's body. The host stands the rig on the body's sole — it knows the capsule's centre is 1.15 m above the feet — and drops the placeholder the moment the mesh is on screen. In `src/prefabs.ts`: ```ts import { Player, prefab } from 'gameable'; export const HeroPrefab = prefab({ name: 'hero', body: { shape: 'capsule', dims: [0.35, 0.8], kind: 'character', mass: 75 }, character: 'char.hero', components: [Player], }); ``` The rig faces `+Z`. A system that moves the entity should also write its facing into `Transform.qx..qw` — the third-person template turns the hero toward its heading, the FPS template points enemies at the player — or the character walks sideways. 3. **Keep publishing state.** The blend picks idle, walk or run from the planar speed in `character.setState(entity, name, vx, vy, vz, grounded)`; the name is your vocabulary and the velocity is what the animator reads. A clip that is not locomotion, such as `wave`, is played by name: ```ts import { character } from 'gameable'; character.setClipWeights(entity, ['wave'], [1], 1); ``` Send it once when the state changes, not every frame. ## Verify ```sh npm run dev -w templates/third-person ``` The hero stands on the floor with its feet on the arena, not hovering and not sunk to the knees, and turns to face the way it walks. `WASD` blends idle into walk; hold `Shift` and the walk blends into a run without the feet skating. Talk to the guide and its head turns toward you and stops near 45°. A capsule where the character should be means the GLB did not load: the bridge warns once with the URL it asked for. On a fresh clone the usual cause is a Git LFS pointer where the 3 MB file should be — `git lfs pull` fixes it, and `npm test -w packages/assets-aosrig` says so explicitly. ## See also - [Characters](../concepts/characters.md) — the three character paths and what each needs - [Animation](../concepts/animation.md) — the locomotion blend and the `extras.aos` clip contract - [Give an NPC a face](./give-an-npc-a-face.md) — the GNM splat head, for a face that moves - `packages/assets-aosrig/README.md` — gameable/aosrig # FILE: docs/recipes/write-a-game-system.md # Write a game system ## Goal Your game runs one more function every fixed step, with access to input, physics, the ECS and the HUD — and it allocates nothing while doing it. ## Files you will edit - `src/systems/patrol.ts` - `src/game.ts` ## Steps 1. Write the system. It is a plain function of the frame context. Hoist every object it needs, because this runs 60 times a second. ```ts // src/systems/patrol.ts import { Enemy, Transform, Velocity, query, type GameContext } from 'gameable'; /** Hoisted: a query term array allocated per frame is a per-frame allocation. */ const ENEMIES = [Enemy, Transform, Velocity]; /** Metres per second an enemy patrols at. */ const SPEED = 1.5; /** * Walk every enemy back and forth along Z, turning every four seconds. * * @param ctx The frame context. * @returns Nothing. */ export function patrol(ctx: GameContext): void { const forward = Math.floor(ctx.elapsed / 4) % 2 === 0 ? 1 : -1; const entities = query(ctx.world, ENEMIES); for (let i = 0; i < entities.length; i += 1) { const e = entities[i]; Velocity.z[e] = SPEED * forward; ctx.physics.moveCharacter(e, 0, 0, Velocity.z[e]); } } ``` 2. Register it. Systems run in declaration order, after the built-ins and before `update()`. ```diff // src/game.ts +import { patrol } from './systems/patrol.ts'; export default defineGame({ player: { prefab: Player, spawn: [0, 1, 0], camera: 'firstPerson' }, - systems: [movePlayer, shoot], + systems: [movePlayer, shoot, patrol], }); ``` 3. Prove it moves something, with a test rather than by looking at it. ```ts // src/systems/patrol.test.ts import { expect, it } from 'vitest'; import { createGuest, Transform } from 'gameable'; import { createGameConfig, createMockHost, simulate } from 'gameable/test'; import game from '../game.ts'; it('moves enemies along Z', () => { const guest = createGuest(createMockHost(), game); guest.init(createGameConfig()); const before = Transform.z[2]; simulate(guest, { frames: 60 }); expect(Transform.z[2]).not.toBe(before); }); ``` ## Verify ```sh npm test ``` The new test passes, and so does the zero-allocation assertion in `packages/sdk/src/packing.test.ts`: your system must not make the pooled command count grow after the first few frames. If it does, you allocated something in the loop — usually a query term array, a vector literal, or a `.map()`. ## See also - [ECS and game code](../concepts/ecs.md) - [The wasm boundary](../concepts/wasm-boundary.md) - [Build the wasm guest](./build-the-wasm-guest.md) - `packages/sdk/README.md` — gameable # FILE: docs/recipes/write-a-guest-in-rust.md # Write a guest in Rust ## Goal Your game logic is a Rust crate compiled straight to a WebAssembly component, loaded by the same `createSandbox({ mode: 'wasm' })` that loads a TypeScript guest. The host never learns which language you used. ## Files you will edit - `Cargo.toml` - `src/lib.rs` ## Steps 1. Install the target once. `rustc` emits a component for `wasm32-wasip2` by itself, so there is no `cargo-component` and no `wasm-tools component new` step. ```sh rustup target add wasm32-wasip2 ``` 2. Declare a `cdylib` with one dependency, and a release profile that cares about size. An empty `[workspace]` stops cargo walking up into a parent manifest. ```toml # Cargo.toml [package] name = "my-guest" version = "0.1.0" edition = "2021" [workspace] [lib] crate-type = ["cdylib"] [dependencies] wit-bindgen = "0.62.0" [profile.release] opt-level = "s" lto = true codegen-units = 1 strip = true panic = "abort" ``` 3. Generate the bindings from the engine's WIT — **do not copy it into your crate**, or it will drift from the host — and implement the five exports. ```rust // src/lib.rs wit_bindgen::generate!({ path: "../../wit", world: "game-module" }); use crate::gameable::engine::env; use crate::exports::gameable::engine::game::{FrameInput, FrameOutput, GameConfig, GameError, Guest}; struct Component; impl Guest for Component { fn init(config: GameConfig) -> Result<(), GameError> { // Seed here, never at module scope, exactly as in the TS guest. STATE.with(|s| s.borrow_mut().rng = (config.seed ^ env::seed()) | 1); Ok(()) } fn tick(input: FrameInput) -> FrameOutput { /* … */ } fn shutdown() {} fn snapshot() -> Vec { /* … */ } fn restore(bytes: Vec) -> Result<(), GameError> { /* … */ } } export!(Component); ``` The `Guest` methods are free functions — there is no `self` — so state is global. `thread_local! { static STATE: RefCell }` keeps that safe with no `unsafe`, and wasm is single-threaded so the borrow never contends. 4. Build and transpile. Two steps, not three: cargo produces the component, `jco transpile` produces the loader, with the same flags the TypeScript guest uses. ```sh cargo build --release --target wasm32-wasip2 npx jco transpile target/wasm32-wasip2/release/my_guest.wasm \ --instantiation async --no-nodejs-compat --name game -o dist/guest ``` `examples/wasm-guest-rust/build.mjs` wraps both steps, verifies the output really is a component (preamble `00 61 73 6d 0d 00 01 00`, layer 1, not a core module's `00 61 73 6d 01 00 00 00`) and prints raw and brotli sizes. ## Verify ```sh node examples/wasm-guest-rust/build.mjs GAMEABLE_BOUNDARY=1 npx vitest run -c tests/boundary/vitest.config.ts tests/boundary/rust-guest.test.ts ``` The build prints a component around 107 KiB (39 KiB brotli) against the QuickJS guest's ~2.1 MiB, and all eleven boundary assertions pass: the frame-0 `spawn` and `add-body` commands, a stride-12 transform buffer, the `raycast` import round trip, the HUD, and a snapshot/restore that hashes identically. ## See also - [The wasm boundary](../concepts/wasm-boundary.md) - [Build the wasm guest](./build-the-wasm-guest.md) - `examples/wasm-guest-rust/README.md` — the worked example - `packages/wasm-host/README.md` — gameable/host # FILE: docs/troubleshooting.md # Troubleshooting Symptom first, then the fix. ## Setup ### `npm install` fails with `EBADENGINE` `.npmrc` sets `engine-strict=true` and the repository requires Node >= 24. Check `node --version` against `.nvmrc`. Do not work around it by disabling `engine-strict`; install the right Node. ### ESLint reports `no-bare-three-import` You wrote `import { Mesh } from 'three'`. Use `three/webgpu` (renderer and core), `three/tsl` (node materials) or `three/addons/...` (loaders and controls). The bare entry point pulls in the WebGL renderer and can create a second three singleton in the bundle, which breaks `instanceof` checks in confusing ways. ## Rendering ### Nothing renders and the console mentions `requestAdapter` WebGPU is unavailable. Check `chrome://gpu`. Splat worlds fall back to the WebGL backend; splat **characters** do not — `engine.caps.characters` will be `false` and `createCharacter` rejects with `CharacterUnsupportedError`. ### Splats look correct but transparent objects flicker through them Splats draw after opaque geometry with depth test on and depth write off, and the sRGB splats (the default) reach the frame as one transparent layer placed at the nearest splat. A transparent mesh that intersects a splat volume, or sits between two splat objects, has no correct ordering. Move the mesh out of the volume, or make it opaque. ### A splat draws nothing and the console asks for an sRGB pass Attach one: `attachSrgbPass(renderer, scene)` from `gameable/core` (the engine does it itself). ### `renderer.backend.device` is undefined You read it before `await renderer.init()`. Bootstrap order is `initWebGPUPatches()` -> `renderer.init()` -> hand the device to onnxruntime-web -> create lift pipelines. ## Wasm guest ### The game behaves differently in `npm run dev` and `npm run build` That is a parity bug between direct and wasm mode, and it is a real bug, not a configuration issue. Run the parity test; it hashes transform buffers from both modes over the same tape. ### Every run produces identical "random" numbers You seeded at module scope. Wizer snapshots the QuickJS heap at build time, so module-level state is frozen into the binary. Seed from `env.seed()` inside `init()`. ### `TextDecoder is not defined` (or a timer API is missing) QuickJS does not ship the whole web platform. The SDK prelude polyfills what the engine needs; if you need something else, add it to the prelude rather than reaching for a browser global in guest code. ### A command had no effect this frame Commands are batched into `frame-output` and applied by the host afterwards. You cannot read back the result of a spawn in the same tick. The guest mints handles precisely so you do not need to. ## Assets ### `Unknown asset id` The id is not in `assets.json`, or you used a path. Assets cross the boundary as string ids only. Add a manifest entry; `docs/schemas/assets.schema.json` is the shape it must have. ### A binary file is a text stub after cloning Git LFS is not installed and the file is LFS-tracked. Install LFS and `git lfs pull`. Note that `fixtures/**` and `tests/fixtures/**` are deliberately **not** LFS-tracked, so tests run on a clone without it. ## See also - [Install](./start/01-install.md) - [Glossary](./glossary.md) - [The wasm boundary](./concepts/wasm-boundary.md) # FILE: docs/glossary.md # Glossary Terms that mean something specific in this repository. **AAM** — AvatarOS Asset Manager. The optional remote asset backend behind `gameable/aam`. It produces ordinary manifest entries; it is not a second addressing scheme. **Action** — A named input, resolved by `gameable/input` and read as `input.pressed('fire')` or `input.axis2('move')`. Game code names actions; only the action map names keys. **`alpha`** — The fraction of a fixed step left over in the loop's accumulator when a frame renders, in `[0, 1)`. How far to blend a transform from where it was at the last fixed step to where it is now. Passed to `update(dtReal, alpha)` and to `writeInterpolated`. **`.aosrig`** — The baked rig pack a `RigBackend` consumes: a JSON header plus neutral vertices, an expression basis, skinning weights, joints and topology. Produced offline by `packages/character/tools/gnm_pack.py` from an aosRig `BakedHead`. **ARKit-52** — The 52-blendshape facial expression space face clips are authored in. Mapped into a bundle's own expression space at load time, never hardcoded. **`BakedHead`** — The numpy-only artefact aosRig exports from GNM: neutral mesh, expression basis, eye joints, skinning and topology. The engine's handoff from the Python side. **bitecs** — The ECS library. Version 0.4, structure-of-arrays, and it lives inside the wasm guest. **Branch** — One part an exporter split an avatar into: `head`, `eyes`, and on a clothed bundle `top`, `bottom`, `hair`. Each has its own decoders and its own slot range inside one animated splat. **Bundle** — A character's shipped directory: `scene.json` plus decoders, geometry and appearance data. **Command** — A structural change the guest requests, as a variant in `frame-output`: spawn, despawn, add-body, play-sound, and so on. Commands are batched; they do not return values in the same frame. **Component (ECS)** — A typed column of data in bitecs, read as `Transform.x[entity]`. Not to be confused with a wasm component. **Component (wasm)** — A WebAssembly Component Model artifact: the compiled game module, built with `jco componentize` and loaded with `jco transpile`. **Direct mode** — `createSandbox({ mode: 'direct' })`. Runs the game's TypeScript through the same guest runtime as wasm, in the host's realm, for fast iteration. Parity with wasm mode is enforced by a test, not by discipline. **`gameable-source` condition** — The package export condition that resolves to `src/index.ts` instead of `dist/`. Why `npm install` is the only build step. **Engine module** — `{ id, order?, init, fixedUpdate?, update?, dispose }`. How every host subsystem plugs into the loop. **Expression space** — What a bundle expects `setExpression` to be given: `arkit52` (52), `gnm` (387), or `gnm68` (68). Declared in the bundle, because two spaces that share a width are otherwise indistinguishable. **Fixed step** — The simulation quantum, `1/60 s`. `fixedUpdate(dt)` always gets that `dt`, runs 0 to 5 times per frame, and is the only place deterministic work belongs. Rendering is not fixed-step; it happens whenever the browser asks. **`frame-input` / `frame-output`** — The two records that cross the wasm boundary once per fixed step. Input carries time, input state, body state, events and, in a room, every player's input (`players`); output carries transforms, commands, the commands applied on the authority only (`local-commands`), the camera and the HUD. **Gaussian splat** — A point primitive with a position, a 3D covariance, a colour and an opacity. Worlds and characters are both made of them. On the GPU it is 52 bytes: centre, two covariance halves and a packed colour word. **GNM** — Google's parametric head model. 253 identity and 383 expression parameters, Apache-2.0, Python only. The second `RigBackend`; it reaches the browser as a bake, never as an inference session. **Guest** — The wasm side: game logic, the ECS, the SDK runtime. **Handle** — A stable integer that stands in for a string across the boundary. An asset handle is a 1-based `u32` assigned in manifest order (`0` means "no asset"); entity and body handles are minted by the guest. **Host** — The browser side: renderer, physics, input, audio, assets, characters. **`head_ext`** — GNM's per-frame input: 387 floats, 383 expression coefficients followed by `[pitchL, yawL, pitchR, yawR]` gaze angles in radians. **jco** — The JavaScript Component Tools. `jco componentize --backend qjs` builds the guest; `jco transpile` makes it loadable in a browser. **Jolt** — The physics engine, `jolt-physics` 1.1, compiled to wasm. **Lift** — The WGSL compute pass that turns character vertices and decoder output into gaussians written straight into GPU storage buffers. Two passes: frame and bounds, then eigen-decompose and pack. **`llms-full.txt` / `llms.txt`** — The generated core documentation corpus and its index, committed to the repository and size-budgeted by `docs:lint`. Topics with a bundle of their own (`llms-multiplayer.txt`, `llms-character.txt`) are not in the core corpus, which points to them. **Manifest** — `assets.json`. Maps string ids to files, types and metadata. The only thing that knows where bytes live. **ORL** — OpenRigLogic. The first rig backend: the character's own DNA, evaluated by a vendored wasm module, then blendshape deltas and skinning on the GPU. **POC** — `F:\work\aos\aos-threejs-poc`, the React-Three-Fiber prototype the character runtime is ported from. **Prefab** — A pure description of an entity — asset, body, health, components — built by `prefab()` at module scope and instantiated with `ctx.spawn(...)`. It allocates nothing and mints no ids until it is spawned. **Rig backend** — The pluggable first stage of the character pipeline: controls in, posed vertices in a GPU buffer out. `orl` and `gnm` ship, and a bundle's `scene.json` names which one posed it. **Sandbox** — The interface the host uses to run game logic: `tick(frame-input) -> frame-output`, in `wasm` or `direct` mode. **Slot range** — A contiguous span of gaussians inside one animated splat, owned by one branch of a character. Slots are fixed and never compacted: a culled gaussian is written with alpha 0 and keeps its address. **SPZ** — The recommended compressed splat container format. **System** — A plain function of the frame context that the guest runs every fixed step, in declaration order. It must not allocate. **Wizer** — The pre-initialiser that snapshots the QuickJS heap at build time. Why module-level state (including a seeded RNG) must be created in `init`, not at import time. **WIT** — The interface description language for the component boundary. The world is `gameable:engine@0.2.0 / game-module`, in `wit/`.