# 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 the template `templates/ROUTING.md` picks for the game, or run `npm create gameable my-game -- --template action`. 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. Physics: see llms-physics.txt Race kit: see llms-race.txt Multiplayer: see llms-multiplayer.txt Characters: see llms-character.txt Headsets and phones’ rooms (VR and AR): see llms-xr.txt Talking characters (AI NPCs, voiced conversation, Convorcher): see llms-talking-character.txt Gameplay building blocks (timers, tweens, triggers, damage, inventory, abilities, quests, waves, turns) and a fighting game: see llms-gameplay.txt Music, effects and rhythm (beat clock): see llms-audio.txt Navigation and AI (tower defense / RTS): see llms-strategy.txt Game screens and UI (menus, cards, trivia, shops): see llms-ui.txt Effects (hit sparks, fire, bloom): see llms-effects.txt Actors and level files: see llms-actors.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. ## Look the game up first Asked for a game ("make a mario kart", "a maze where ghosts chase you")? Before you pick a template, run `npx gameable games find ""`. It prints the best three of the researched game cards (`games/INDEX.md`; each card is `games/.md`) with scores, the template, its status and `weakMatch`. Status `exists`: start from that template. `port` or `planned`: start from its base template and build the card's rules, controls, HUD and look with the actors and components in its `build_with`. On `weakMatch` or `unsupported`, build from the closest card and say what the request needs that no card covers. Never ship a trademarked name, character or art. ## 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. - `llms-audio.txt` — music, effects and rhythm. - `llms-strategy.txt` — tower defense or RTS. - `llms-race.txt` — obstacle races and wheeled circuit racing. - `llms-ui.txt` — menus, cards, trivia, shops. - `llms-actors.txt` — actors, components, premade actors, level files and the game framework. 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. ## Build with the engine's actors first Check `llms-actors.txt` before writing systems: WheeledVehicle + vehicleMovement, createCircuitRace + CheckpointVolume lap gates, VehicleGripVolume, bindVehicleInput, AIController followRacingLine/chaseVehicle and cameraArm. Compose these pieces; see [Racing and vehicles](docs/recipes/racing-and-vehicles.md). ## Starting play A title screen registers Play with `onStart` from `gameable/host`; its button calls `startGame({ reason: 'player' })`. Guest latches use `ctx.started` or the `start` event. Capture/playtest call `window.__gameable.startGame()` with their reason. Keep pointer lock and audio optional; skip pointer lock for capture. See [Start a game](docs/recipes/start-a-game.md). ## 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`. 15. **Game code turns and aims with the SDK, never its own math**: `setYaw`, `faceToward`, `yawRotation`, `camera.lookAt`. Templates and recipes show the helpers, not quaternions. ## 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/world` | Made worlds: a world package loaded and made playable | | `packages/xr` | Headsets and phones' room views: Enter VR, View in your room | | `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`. Add custom logic to its `defineGame` systems; for example, this minimal game publishes a fire-button pulse to the HUD: ```ts import { defineGame, MOUSE_BUTTONS } from 'gameable'; export default defineGame({ systems: [ (ctx) => { const fire = ctx.input.pressed('Space') || ctx.input.mousePressed(MOUSE_BUTTONS.LEFT); ctx.hud.set({ fire }); }, ], }); ``` In direct dev mode, a broken edit keeps the running game and reports the error. A good edit reloads the page; state is not kept yet. ## 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 current browser. The character draws with WebGPU where the browser has it, and on the WebGL2 fallback (a phone or tablet without WebGPU) otherwise, 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. To keep the demo and add yours beside it, name a folder: ```sh npm run import:character -w examples/wasm-hello -- "/absolute/path/to/extracted-character" my-character ``` That one loads with `?character=my-character` on the URL; without it the page loads `greeter`. The site ships two this way — the greeter and `steph`, the first character exported from Gameable CC through the engine package. ## 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 sprint, 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: 4, runSpeed: 6.5, 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 course, with the **race** kit: a multiplayer game that already works, for you to change. ## 1. Pick a kit ```sh npm create gameable -- --list ``` prints every template, one line each, with race marked as multiplayer: | Kit | Players | What it is | | ------ | ------- | -------------------------------------------------------- | | `race` | 1-12 | obstacle race with jumps, sweepers, checkpoints and places | Race is also hosted on the [Play](../play.md) page. **Play together** 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-race -- --template race cd my-race npm install npm run build:guest # Play Solo's authority runs the wasm guest npm run dev # http://localhost:5199 ``` The page plays solo: a room of one, the authority in the page. WASD walks, Space jumps and the mouse orbits. Solo starts after a three-second wait and a three-second countdown. Falling returns you to your last checkpoint. Paste a published link in **Your character** or use `?look=` to choose your look. ## 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:5199/?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 racers on one course, each moving in their own tab and seen in the other. The second player starts a five-second wait, followed by a three-second countdown. Race to the finish line to see your places. 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. ## 5. Change something The layout and tuning are in `src/course.ts`. Change `walkSpeed` and `runSpeed` in `RULES` from `4` to `5`, restart `serve --direct`, and reload both tabs. Both racers now move faster; prediction uses the same movement system on the page and the authority. Rebuild the guest before trying Play Solo after an edit. Run the kit's tests, which drive the authority headlessly without a 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): the hosted games - [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 | | [Gameplay building blocks](./gameplay.md) | Timers, tweens, zones, health, bags, quests, waves and turns | | [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 `gameable`: page `assetUrl(id)` after engine creation, or `gameUrl(path)`. `gameable rename-asset old new` fixes literals; `aliases` keeps old ids. `include`/`exclude`: manifest-relative globs; excluded entries fail check. `bakedLighting` is a splat's baked re- or de-lighting (a `.light.bin` from `examples/splat-bake`), resolved like `src` and drawn with the splat. **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"] } ] } ``` ## Game addresses `game:x` is the canonical address for a file in the game's folder; a leading `/` still works but is legacy. `gameUrl` (also `resolveGameAddress`) from `gameable/assets` resolves either form and safely accepts bundler `?url` addresses, even when resolved again. `gameRoot()` returns the folder; `setGameRoot(root)` overrides it. `/services/`, `/companion/`, `/api/` and `/local/` (`SITE_ROOTS`) stay at the site root. Unsafe addresses throw `GameAddressError`: controls, backslashes, unsupported schemes, or `game:` paths climbing above the folder. ## 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, with `assets.registerLoader(type, loader)`. 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` using the game-root rules above. When `baseUrl` carries a scheme, resolution uses `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 | video`; 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. A compiled game is never handed a case it was compiled without: the host leaves it out and warns once. ## 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. A character is sorted by `StableDepthSort` (`packages/splat/src/stableSort.ts`), a stable radix sort, so points that have not moved keep their draw order from frame to frame. Everything else on WebGPU uses `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. Characters that touch (a fight, a hug, a dance) are drawn that way with `createSharedCharacterSplat` (`gameable/three`): each is loaded with `{ shared: cloud }` and writes its points into a slice of the one cloud, which sorts and draws them all together. - **A rigid thing that moves inside a static splat is a part of it.** `createSplatParts(place, counts)` grows a decoded asset by room for groups of gaussians; each group is filled from an asset of its own, moved as one by a matrix, and sorted with the place's gaussians, so a trunk made of points is hidden by the beam in front of it. One compute pass writes every part when any of them moved, and the object sorts again that frame. WebGPU only. `gameable/world` uses it for a made world's loose things. - **Several places that overlap are one object too**: `createJoinedSplat` ([Worlds made of several places](../joined-worlds.md)). - 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 by name and weight, **faded**: a change of the set takes `fade` seconds (0.4 by default; `fade: false` snaps), old clips out as new ones in, summing to 1; `speed` is a clip's rate; 3. the locomotion blend, from `state.velocity`, under whatever weight the explicit clips leave, so it is back once they go. `animator.onClipEnd` reports `'ended'` and `'loop'`; the bridge sends them to the guest as `anim-event`s, so a game never times a clip from its length. **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 before mapping. Native guests receive only the mapped blink minus mapped rest; timing continues across both paths. ## 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 The loader computes additive deltas once when preparing a clip. `bakeAdditive` takes the additive type, base pose type and reference frame index that the FBX to glTF conversion strips, and rewrites the clip's keyframes at load time. Graph evaluation blends the baked clip without recomputing its deltas. ## See also - [Characters](./characters.md) - [Play an animation](../recipes/play-an-animation.md) - `packages/animation/README.md` — gameable/animation # FILE: docs/concepts/worlds.md # Worlds A **world package** is one folder a game can load and play at once: the place, every thing in it with a name and a position, what can move, what it sounds like, and where the player starts. A maker writes it (from a picture or a sentence); `gameable/world` reads it. This page is format 1. ## The rules - Metres, y up, right-handed. The origin is where the picture was taken, on the floor. - **Nothing is forced to be points or a mesh.** The place and every thing may carry either form or both (`forms`), and `use` says which a game draws. - Every thing has a label and a place in the world, whether it is its own object (`loose`) or part of the place (`fixed`). - Files are named by a path inside the folder. The engine turns them into asset ids, `..` (`forgotten-attic.steamer-trunk.mesh`); game code never sees a path. - A reader ignores fields it does not know, and refuses a newer `version`. - `facing` and `yaw` are degrees about y, as a turn of a figure whose front is +z: 0 looks along +z, 90 along +x. ## The folder ``` / world.json the record below place/ place.spz the place as points (full) place.lite.spz a lighter copy for phones (optional) place.mesh.glb the place as a mesh (optional) collision.glb what stops a body (or heights.bin: a height map, float32, row by row) panorama.webp the view all round from the origin: the sky, and the light things// thing.glb the thing as a mesh, in metres, origin at the middle of its base thing.lite.glb a lighter copy for games (optional) thing.spz the thing as points, lit to match the place (optional) hull.glb a simple closed shape for collision (optional; else a box from `size`) impact-1.mp3 ... the sounds it makes when it hits something sounds/room-1.mp3 ... loops for the place ``` ## world.json ```jsonc { "version": 1, "id": "forgotten-attic", "name": "Forgotten attic", "about": "A long attic under a pitched timber roof.", "kind": "room", // "room" (walls all round) | "open" (a field, a street) | "object" (no place: see "A thing by itself") "place": { "forms": { "splat": { "file": "place/place.spz", "lite": "place/place.lite.spz" }, "mesh": { "file": "place/place.mesh.glb" }, // optional }, "use": "splat", // The place's own files to metres, floor at y = 0: times `scale`, half a // turn about x when `flipY`, then up by `floorOffset`. "fileTransform": { "scale": 1.0856, "flipY": true, "floorOffset": 0.6354 }, "collision": { "shape": "mesh", "file": "place/collision.glb" }, // or { "shape": "heightfield", "file": "place/heights.bin", "columns": 256, // "rows": 256, "cell": 0.25, "origin": [-32, 0, -32] } "panorama": "place/panorama.webp", "spawn": { "position": [0, 0, 0], "facing": 180 }, "eyeHeight": 1.6, "bounds": { "min": [-3, 0, -5.2], "max": [3, 2.7, 5.1] }, "walk": { "shape": "inside-collision" }, // open: { "shape": "radius", "centre": [0, 0, 0], "radius": 14, "fadeFrom": 11 } "light": { // read from the panorama; three's own units "sun": { "direction": [0.1, -0.5, -0.86], "colour": [1, 0.82, 0.62], "strength": 2.4 }, "ambient": [], // nine red-green-blue terms: a second-order light probe }, }, "things": [ { "id": "steamer-trunk", "label": "steamer trunk", "kind": "chest", "about": "Large travel trunk in dark worn leather.", "state": "loose", // its own object "placement": { "position": [-2.1, 0, -2.4], "yaw": 35, "scale": 1 }, "size": [1.1, 0.72, 0.68], // width, height, depth "rests": "floor", // "floor" | "on:" | "hanging" | "wall" "stand": { "position": [-1.3, 0, -1.9], "facing": 215 }, "forms": { "mesh": { "file": "things/steamer-trunk/thing.glb", "lite": "things/steamer-trunk/thing.lite.glb", }, }, "use": "mesh", // kind: "dynamic" | "static" | "none"; shape: "box" | "hull" | "mesh" "body": { "kind": "dynamic", "shape": "hull", "mass": 18, "material": "wood" }, "sounds": { "impact": ["things/steamer-trunk/impact-1.mp3"] }, }, { "id": "wardrobe", "label": "wardrobe", "kind": "wardrobe", "state": "fixed", // no files: it is in the place and its collision "box": { "min": [-3, 0, -4.4], "max": [-2.4, 2.1, -2] }, "stand": { "position": [-1.7, 0, -3.2], "facing": 270 }, }, ], "sounds": { "room": ["sounds/room-1.mp3"] }, } ``` ## What a game gets - The place drawn, with its collision, its sky and its light, and every loose thing at its spot: a body that can be pushed, with its own sound on a real knock (`packages/world/README.md`). - **The list of things**, loose or fixed: label, kind, box, where to stand, and where it is now. The host asks `engine.get('world')`; game code asks the SDK's `things` facade (`things.find('the trunk')`, `things.near(point, 2)` nearest first, `things.standFor(thing)`), which answers inside the guest from the list the `loaded` event carried. - The start (`things.world.spawn`, `eyeHeight`) and the walk limit (`things.limit(x, z, velocity)`: nothing in a room, a soft edge in the open). - `world-event`s in `ctx.events`: `loaded`, `hit` (which thing, how hard, 0 to 1), `moved` (where it is now), `unloaded`. Commands back: `things.push`, `things.move`, `things.reset`. At every load, mesh and heightfield worlds with a radius walk limit sample the static floor: an unsupported spawn moves to the nearest supported sample, and poor floor coverage reduces the walk radius; corrections are logged as `[world]` errors. ## A thing as points Whichever form of the loose things `use` names, the host can ask for either: `engine.get('world').drawThingsAs('splat')`. ```jsonc "forms": { "mesh": { "file": "things/steamer-trunk/thing.glb", "lite": "things/steamer-trunk/thing.lite.glb" }, // The points are in the thing's own frame, as its model is: metres, y up, the origin at the // middle of its base. `points` is how many the file holds; `light: "place"` says they already // carry the place's light (worked out per point by the maker, for where the thing stands). "splat": { "file": "things/steamer-trunk/thing.spz", "points": 250000, "light": "place" }, }, "use": "mesh", ``` In a place made of points the thing's points are drawn inside the place's own object, so the two sort together (what stands in front of the thing hides it), and it moves and turns with its body. This is [`createSplatParts`](./splats.md#draw-order) underneath, and it needs WebGPU; on the WebGL fallback a thing's points are an object of their own, drawn over the place's points wherever they stand. ## A thing by itself A world may have no place (`"kind": "object"`): one thing, made alone, standing in front of the start. Nothing was pictured, so there is no `fileTransform`, panorama or picture. ```jsonc "kind": "object", "place": { "forms": {}, "use": null, // no place: the loader draws a plain world "background": [1, 1, 1], // its colour, linear: the floor and all round (white when absent) "collision": { "shape": "plane", "height": 0 }, // a level floor at y = height, without end "spawn": { "position": [0, 0, 0], "facing": 180 }, "eyeHeight": 1.6, "walk": { "shape": "radius", "centre": [0, 0, 0], "radius": 17, "fadeFrom": 14 }, "light": { /* as for any place: an even white studio */ }, }, "things": [ /* the thing, loose, along -z from the start, its front toward the player */ ], ``` The loader draws the plain world: the colour all round, a floor that is solid and seen (it takes the thing's shadow from the package's sun), and no horizon or wall: the floor fades into the colour with distance. The thing loads as any loose thing does; its `kind` is the package's own word (`vehicle`). A thing made alone can be heavy; its record says how heavy: ```jsonc "mesh": { "file": "things/pink-menace/thing.glb", "triangles": 1889260, "textures": [[4096, 4096], [8192, 8192], [4096, 4096]], // a colour texture may be 8192 a side "lite": "things/pink-menace/thing.lite.glb", "liteTriangles": 299997, "liteTextures": [[1024, 1024], [1024, 1024], [1024, 1024]], }, ``` On a desk the full copy is taken, textures of 8192 and all; with `lite: true` (a phone) the lighter one: fewer triangles when the maker made it so (`liteTriangles`), textures 1024 a side. ## A thing with moving parts A thing may be **whole parts joined at named joints** instead of one piece: a bike's wheels, arms and handlebars, a chest's lid. Nothing is bent. Every part is a named node of the thing's own model file, so a reader that ignores `parts` still draws the thing whole. ```jsonc "parts": [ { "id": "body", "parent": null, "joint": null, "forms": { "mesh": { "node": "body" } }, "body": { "shape": "hull", "hull": "things/bike/parts/body.hull.glb", "mass": 276 } }, { "id": "front-wheel", "parent": "front-arm", "joint": "front-wheel-spin", /* ... */ }, ], "joints": [ { "id": "steering", "kind": "hinge", "parent": "body", "child": "handlebars", "at": [0, 0.665, 0.514], "axis": [0, 0.894, -0.447], // a point on the axis, and its direction "limits": [-14, 14], "rest": 0 }, // degrees (a slide: metres); null = turns freely ], "rider": { "seat": { "part": "body", "position": [0, 0.806, -0.253], "forward": [0, 0, 1], "up": [0, 1, 0] } }, "vehicle": { "kind": "motorbike", "mass": 350, "wheelbase": 1.829, "steeringLimit": 14, "wheels": [ { "part": "front-wheel", "axle": [0, 0.349, 0.917], "radius": 0.349, "width": 0.332, "spin": "front-wheel-spin", "travel": "front-travel", "steer": "steering", "driven": false, "rise": { "up": 0.045, "down": 0.027 } } /* , the rear */ ] }, ``` Everything is in the thing's own frame at rest (metres, y up, front +z, so its left is +x): the parts' triangles, a joint's `at` and `axis`, the rider's spots, an axle. A joint moves its child and all that hangs from it: a hinge turns them right-handed about the axis, a slide moves them along it. Work down from the root (`jointMatrix`, `orderParts`). The loader draws the thing from its parts and keeps each joint's value: `engine.get('world').setJoint(thing, 'steering', 10)`, `jointOf(thing, id)`. Game code asks the same with `things.joint(thing, 'steering', 10)`; a thing's joints are in its list entry (`thing.joints`: id, kind, min, max, free, rest, and `value`, `at`, `axis`). A joint with limits that stands somewhere new is told to the game as a `joint` event (which joint, its value), so `value` is where it stands now; a wheel on its axle is never told. A thing with a `vehicle` of kind `motorbike` and two wheels **drives**: its body is its root part's hull, clear of the ground, and its wheels are Jolt's own (`createMotorcycle`): they hold it up on their springs, lean it into a turn and keep it upright at rest. `things.drive(thing, throttle, right, brake)` is the rider's wish, held until the next; the wheels' spin, the arms' travel and the steering joint follow the physics every step. `thing.drives` says so; under way, a game hears `moved` ten times a second. A wheel may name `steerAlso` (joints that turn by its angle: handlebars, when the wheel turns in its own arm) and no `travel` joint (no springing drawn; the physics keeps a centimetre of give). A steering joint with less room than `steeringLimit` (a wheel in a housing) shows its share of the turn (`steerShown`); the thing still steers by `steeringLimit`. Pull and brakes grow with `vehicle.mass` and wheel radius above a 350 kg bike's on 0.35 m wheels (never less), so a heavy thing gets away and stops as briskly; under throttle it holds its top speed. Its hold of the lean grows with its weight too, so it leans into a turn, never out; `vehicle.tread` (0.2 to 1, the share of a tyre's width that stands flat) says how far: narrower, further. A `vehicle` of kind `monowheel`, ONE wheel, drives too by `things.drive`: a great ring the thing stands in, its frame inside. ```jsonc "vehicle": { "kind": "monowheel", "mass": 250, "steeringLimit": 0, "lean": 47, "wheels": [ { "part": "ring", "spin": "ring-spin", "travel": null, "steer": null, "driven": true /* , axle, radius, width, rise */ } ], "controls": [ { "joint": "left-lever", "steer": 12, "throttle": 5, "brake": -6 } /* , the right */ ] }, ``` Its body is the thing's hull cut clear of the ground at its lean, its wheel cast under its middle (`createMonowheel`). It keeps level front to back (the frame swings a little under power and brake, then settles), leans into a turn (at most `lean` degrees; 47 without), stands upright at rest and holds its heading when nothing is asked (`MONOWHEEL`). The ring's spin joint follows the ground covered. **The parts a rider works.** `vehicle.controls` names joints (a lever, a pedal), on either kind, that follow what is asked: each gives what full right steering, full throttle and full brake add to its rest value, within its limits (`controlValue`). A grip on such a part goes with it, and the rider's hand. **Where a rider meets it.** `rider` names spots, each on a part: `seat`, `gripLeft`, `gripRight`, `footLeft`, `footRight`. The loader makes an object at each, in the scene, under its part, so a grip turns with the handlebars and every spot leans and moves with the thing: `engine.get('world').rideOf(thing)` gives them (`seat`, `hands`, `feet`, `spots` by name, and `floor`, the middle of the thing's base). Each object's +z is the way a character there faces, its +y up, its +x the character's left (a grip's +x runs along its bar; `spotFrame`). Game code has the same spots in its list entry (`thing.rider`: each with the joints that move it) and asks where one is now with `things.spot(thing, 'gripLeft')` (`spotOf`). **A character rides it.** `things.getOn(thing, entity)` sits a character on those spots and `things.getOff(thing)` takes it off (the `thing` command's `ride`). The host seats it every frame through the character bridge's `seat` (hand the bridge to `createWorldBridge({ characters })`), in the pose `createSeatedPose` of `gameable/animation` works out from the character's own skeleton (lying back in a seat that leans back, leaning with the thing), so any character whose joints the engine knows rides with no numbers of its own. The entity stays where it was: take its body out first (`physics.setEnabled(entity, false)`) and move it beside the thing before getting off. Getting on and off are cuts. **In pieces.** Parts on `break` joints are one body until `things.breakApart(thing)`; then each piece (`: `) is its own body (a `static` one stays put). `things.reset` mends it. `body.hulls`: a body of several shapes. ## See also - [Load a made world](../recipes/load-a-made-world.md) - [Worlds made of several places](../joined-worlds.md): `places`, `backdrop`, `routes` - [The wasm boundary](./wasm-boundary.md) — events in, commands out - [Physics](./physics.md) - `packages/world/README.md` — gameable/world # 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, additive animation 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`, `advanceLocomotionPhase`, `createLocomotionPhase`, `timeScaleFor`, `validateLocomotionIndex`. - A locomotion set's names: `LOCOMOTION_CLIP_NAMES` (walk, jog, run, sprint, the side steps, the turns, a start, a stop, a jump); the bridge loads a set by URL. - 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. - `createSeatedPose({ root, joints? })` — a character sat on something that moves (a vehicle's seat, grips and foot rests, given each frame as places in the world): the hips on the seat, each hand held to its grip and each foot to its rest by a two-joint solve, the back leaning forward just as far as the hands need, the head looking where the seat goes, the body leaning a little into a turn. Worked out from the skeleton alone (`SEATED` holds the shares and angles), for the skeletons the engine knows (`seatedJointsFor`: `AOSRIG_SEATED_JOINTS`, `SOMA_SEATED_JOINTS`, `GNM_SEATED_JOINTS`). `apply(spots, dt)` after the animator, `restore()` before it next runs; `lean` and `miss` (how far each hand and foot is from its spot) say how it went. The character bridge's `seat` runs it. ## Gotchas - Explicit clip weights (`state.clips`) fade: a change of the set takes `fade` seconds (0.4 by default; `animator.fadeSeconds` later; `fade: false` snaps), old clips out as new ones in, summing to 1 throughout. The first set, and a set after everything faded out, apply at once. A once-only clip re-requested restarts once it has faded out; sooner, send `time: 0`. - The locomotion blend fills the weight the explicit clips leave, so it is back once they are taken away or end. - The walk blend reads a locomotion GROUP (the gaits by speed with the standing clip), never every clip tagged locomotion; the bracket's two clips share ONE phase (`advanceLocomotionPhase`), their mixer rate at 0, a `'loop'` reported as it comes round. - `state.clips[i].speed` is the clip's rate (`set-clip-weights`' `time-scale`). - `animator.onClipEnd` reports `'ended'` (a once-only clip finished) and `'loop'` (a looping one came round), from inside `update`. - 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. Both blink: native input adds only the mapped blink minus mapped rest. - Make a seated pose while the bones stand at rest (before the animator has moved them): it reads the lengths, the palms and the soles then. It writes over the bones the animation posed, so `restore()` must run before the animator's next `update`. - `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, so a walking character's head cannot twist past its neck limit. - **A graph handed to `setGraph` is immutable.** Its indices and rule graphs are memoised on the identity of the authored objects, so editing a node array in place is not re-read: build a new graph object and call `setGraph` again (free; the caches are `WeakMap`s). - The gesture layer is one slot. Register a gesture clip with `{ additive: true }` or the base layer's weight pass zeroes it every frame. - The gesture channel writes an action's blend mode, loop style and clamp flag once, when the gesture reaches it; only the weight is written per frame. - `bones.root` must name a bone this rig has; otherwise one warning at construction and `bodyPose.rootPos` stays at zero. - `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()`; never mix it with `Date.now()`. The animator's default clock is its own accumulated `dt`, so gestures, face clips and blink pause with the engine, slow with `timeScale` and replay identically; `clock: defaultClock` is for an animator outside the loop. A sub-runtime made on its own (`createBlinkRuntime()`) defaults to wall clock. - `MoveCompletedEvent` is pooled per movement component. Read it inside the sink; copy anything you need to keep. - Locomotion clips are CC0 and retargeted offline; no Mixamo content (`assets/mixamo_to_mh.json` is named for the naming convention only). - `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 | video`), `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-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 use different bone names. - `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 uses a different default bone naming scheme** (`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/bundle/README.md # gameable/bundle ## What Versioned template routing metadata, bounded agent settings edits and deterministic scoring live here alongside the schema 2 immutable game bundle manifest (with schema 1 read compatibility), path validation and Node integrity helpers. A bundle contains `manifest.json`, `settings.json`, `assets.json`, all transpiled guest files and local asset payloads. The inventory covers every payload; `manifest.json` is excluded because it contains the hashes itself. ## When to use Use this contract when producing or validating a local game bundle for a publisher or resolver. Publication and published-version selection belong to separate services. ## Install ```sh npm install gameable ``` ## Minimal example ```ts import { validateBundleDirectory } from 'gameable/bundle/node'; const manifest = validateBundleDirectory('./dist/games/tiny-game/1.0.0'); console.log(manifest.schemaVersion !== 1 ? manifest.engine.compositionId : manifest.engine.version); ``` ## API - `validateTemplateManifest(value)` validates the version 1 `template.json` routing contract. - `validateTemplateSettings(manifest, patch)` checks dotted setting keys against editable bounds. - `scoreTemplates(templates, request, threshold?)` ranks candidates with reasons and weak-match flags. - `validateBundleIdentity(value)` validates identity before a build starts. - `validateBundleManifest(value)` validates structure, identities and relative paths. - `compositionId(composition)` derives the stable runtime storage id. - `canonicalComposition(composition)` produces the JSON used for hashing. - `validateEngineComposition(value)` validates runtime metadata. - `validateBundlePath(value)` rejects unsafe bundle paths. - `isBundleVersion(value)` recognizes exact semantic versions. - `inventoryBundle(directory)` hashes every payload and rejects symlinks. - `validateBundleDirectory(directory, value?)` validates every size and hash and rejects unlisted files. - `contentType(path)` identifies common game payload MIME types. Build with `gameable bundle --id tiny-game --version 1.0.0 --out dist`. The destination is `dist/games/tiny-game/1.0.0/`; an existing version is never overwritten. Settings preserve features, world, physics, room capacity/rate and declarative camera settings. Schema 2 records `engine: { compositionId, core, modules, guestContract }`. The CLI reads the exact runtime metadata shipped with the installed engine; optional modules have their own exact versions. `compositionId` hashes canonical JSON of core and modules sorted by id, so differing optional versions select different runtimes. Schema 1 retains `engine.version` and its top-level feature modules. Asset sources are relative to the bundle directory; omitted `baseUrl` lets loaders resolve them from the manifest location. HTTP/data references remain external. ## Gotchas Template metadata is separate from bundle payload inventories. See `templates/ROUTING.md` for weights, supported candidates and legacy host-page limits. The guest contract is checked against the WIT package used by the build. Runtime composition comes from the game's installed `gameable/runtime-composition.json`. Settings cannot contain functions. External asset references are not copied or integrity pinned. `manifest.json` itself must be supplied by the trusted publisher/resolver. Node validation rejects symlinks. # 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. - `check` reports unknown asset ids and invalid levels by file and line (`bundle` runs it). - `test` runs `*.gametest.ts`. - `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 - `gameable serve --bundles https://storage.example/prefix/ --bundle-cache ./cache` configures bundle acquisition without baking games into the server image. Storage serves `games//live.json` with `{ "version": "1.0.0" }` and `games///manifest.json` plus every listed relative file. Downloads use plain GET, verify sizes and SHA-256, and publish into the cache atomically. Valid cached versions work offline; failed downloads retry. Publication is owned by a separate service. This currently starts an empty room catalog: opening rooms by game id/version is a separate integration. `--bundles` cannot be combined with `--games` or `--direct`. - **`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/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. Servers import **`gameable/core/headless`**: `createHeadlessEngine`, feature/module utilities and context types without three.js. The root loads three.js; tests enforce that the server entry does not. ## 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, splatLayer }`. - `renderer.splatBlend` — `'characters'` (default) takes flagged characters into the splat layer and leaves other splats in linear light; `'studio'` takes all sRGB splats, `'pass'` uses `attachSrgbPass`, and `'linear'` uses linear light throughout. `engine.splatLayer` exposes `setLensBlur`, or is null with `'pass'`/`'linear'`; `SPLAT_LAYER`, `UNDER_LAYER` and `SPLAT_LAYER_FLAG` identify its layers and flag. - `attachSrgbPass(renderer, scene, { hook? })` — the sRGB pass on its own, for an app with its own three.js loop (also from `gameable/core/render`); `isSrgbPassMember`, `SRGB_PASS_LAYER`. - `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** `installDiagnostics()` keeps 200 diagnostics. `window.__gameableReportUrl` enables fatal-card JSON POSTs: `build,phase,code,message,stack,recent,userAgent,url,description`; queries are stripped; failed POSTs retry once next load. Compiled errors lazily use `guest/game.js.map` for TS positions. - `initWebGPUPatches({ maxStorageBuffersPerShaderStage?, timestampQuery? })` — call before `renderer.init()`. - `createDebugOverlay({ renderer, backendName })` — F3 panel; `createFrameStats(n)` is the window behind it. - `createBadge({ corner?, container?, href? })` — the "Made with Gameable" badge (the mint mark on black, white words), the same on every page. - `createCutscenePlayer()` — a clip drawn over the stage at a moment that matters, with a fade, a poster, a skip and hooks for the host. `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 (the TSL path, or the vertex-stage skin as an option). - The splat layer and the sRGB pass are not both on one scene: the engine hooks the pass only with `splatBlend: 'pass'`. A package with its own loop (`gameable/three`) attaches the pass itself. - `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/race`, 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/world`, `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 A minimal `src/game.ts` that reads a fire button and publishes it to the HUD: ```ts import { defineGame, MOUSE_BUTTONS } from 'gameable'; export default defineGame({ systems: [ (ctx) => { const fire = ctx.input.pressed('Space') || ctx.input.mousePressed(MOUSE_BUTTONS.LEFT); ctx.hud.set({ fire }); }, ], }); ``` ## 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 collects keyboard, mouse (pointer-lock deltas included), touch and gamepad into the packed `input-state` block of `frame-input`, and resolves named actions like `fire` and `move`. Touch goes into the same block (`src/touch.ts`), so a game binds once: the first finger is the pointer (`mouse`, left button held), a tap and a click are both `Tap`, two fingers are the wheel (pinch) and a turn, the thumb sticks a gamepad in the last slot (`TOUCH_PAD_INDEX`; `lx ly rx ry`, the pinch, the turn). The gestures are pseudo-keys: `Tap`, `DoubleTap`, `Hold`, `Drag`, `Pinch`, `StickLeft`, `StickRight`. The state is exactly the WIT record (`keysDown` / `keysPressed` / `keysReleased` as `Uint32Array(8)` bitsets, `mods`, `mouse`, `gamepads`, `focused`), so it crosses the wasm boundary without a translation layer. The key table (`gameable/sdk/keycodes`, shared with the guest) is **frozen forever**: recordings and compiled guests encode the indexes, so codes are only appended. 0..127 are the keyboard, 128.. the appends (the touch gestures first), 248.. the mouse buttons (`Mouse0`..`Mouse4`), so `'LMB'` reads like `'Space'`. ## When to use Always. Every template registers it. Game code in the guest reads it through `sdk`'s `input` facade; host-side code reads the service. `createInputCapture` alone gives the packed state without the engine (a harness, a recorder). ## Install ```sh npm install gameable ``` Workspace installs already include this package. ## Minimal example This example configures host `InputService.actions`. Guests declare `defineGame({ actions })` and read `ctx.input.action('fire')` or `ctx.input.axis2('move')`; see [Read player input](../../docs/recipes/read-player-input.md). ```ts import { input } from 'gameable/input'; const module = input({ pointerLock: true, // Thumb sticks on a touch screen: the walk left, the camera right, drawn on the page. touch: { sticks: 'both' }, overlay: true, actions: { fire: ['LMB', 'GamepadRT', 'Hold'], jump: ['Space', 'GamepadA', 'DoubleTap'], // The touch pad is a gamepad, so the left thumb drives this too. 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 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` `'input'`, `order` `-100`; `init` returns the service, `engine.get('input')`). `options`: `target`, `actions`, `pointerLock`, `preventDefault`, `gamepads`, `gamepadSlots`, `focused`, `onLockDenied`, `touch` (the sticks and the thresholds, or `false`), `overlay` (draw the sticks). - `InputService` — `{ state, actions, requestPointerLock, exitPointerLock, lockDenied, consume, suspend, resume, suspended }`. `suspend` drops what is held, releases pointer lock and lets the DOM events pass by (the browser's context menu opens) until `resume`: an editor over the game does this. - `createTouchLayer(sink, pad, touch, options?)` — the gesture layer the capture drives. `state.touch` is its `TouchState`: `fingers`, the pointer finger's `x y dx dy`, `tapX tapY`, `pinch` (a scale), `turn` (radians), `left` and `right` (`{ active, x, y, originX, originY, touchX, touchY }`). `TOUCH_KEYS`, `TOUCH_PAD_INDEX`, `TOUCH_PAD_AXIS_PINCH`, `TOUCH_PAD_AXIS_TURN`. - `createStickOverlay(options?)` — the rings and knobs (`overlay: true`). - `createInputCapture(target, options?)` — the DOM layer: `{ state, lockDenied, beginFrame, consume, endFrame, requestPointerLock, exitPointerLock, suspend, resume, suspended, dispose }`: `beginFrame` folds the frame's DOM events, `consume` hands one fixed step the edges and deltas accumulated since the previous step, `endFrame` clears the published edges. - `createActionMap(bindings, state?)` — returns `{ names, handle, down, pressed, released, axis2, bind }`. `handle(name)` mints the small integer the readers also accept. Bindings are `'KeyW'`, `'W'`, `'Space'`, `'LMB'`, `'GamepadA'`, `'GamepadRT'`, `'Tap'`, … 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 elements own their keyboard input: typing produces no gameplay keys, and focusing a field releases what is held. - `pressed` and `released` belong to a **fixed step**, not to a rendered frame: published by `fixedUpdate` (`capture.consume()`), cleared by `endFrame`, so read them from a `fixedUpdate`, never from `update`. A frame with no fixed step loses no edge (they wait); a frame with several delivers each edge to the first step only, as a 60 Hz display would have. `mouse.dx` / `dy` / `wheel` accumulate the same way and are handed over whole, once. - A press that starts and ends inside one frame still reports both edges, with `down` false: test `pressed` for a fire button. - Host action-map `axis2` returns a **reused** tuple. Guest `ctx.input.axis2` returns a pooled `{ x, y }` object; read its numbers immediately. - Pointer-lock deltas are non-zero only while the lock is held, and `requestPointerLock` succeeds only inside a user gesture (`pointerLock: true` requests it on `mousedown` for you). - `wheel` is in lines, not pixels (a pixel-mode event is divided by 100). - Blur clears everything held. - Gamepads are polled once a `gamepadconnected` event has fired, which means after the player has pressed a button on the pad. - The last gamepad slot is the touch pad's while touch is on (the default), connected only while a finger is down; three real pads fit in front of it. - A finger's `pointerdown` is `preventDefault`ed and the target gets `touch-action: none`; a page that handles fingers itself (three's `OrbitControls`) passes `touch: false`. - A tap is a one-step pulse (`pressed` and `released` together, never `down`); a hold, a drag, a pinch and a stick are levels. - In the guest, `input.axis2('A', 'D', 'S', 'W')` folds the left stick in (`'right'` or `'none'` as a fifth argument), a finger drag turns the built-in look, and `input.touch` reads the sticks, the pinch and the turn. - The key table is append-only: a code added in the middle silently reinterprets every recording ever made. The keyboard half lives in `gameable/sdk/keycodes` (see [ADR 0013](../../adr/0013-single-key-table.md)); the appends and the pointer block are the host's own. - The action readers take a name or a handle; `actions.handle('jump')` once, kept, makes a read an array index instead of a map lookup. - `focused` seeds from `document.hasFocus()`; jsdom answers `false`, so a headless test that dispatches keys at the window passes `focused: true`. - A refused pointer lock is reported, not thrown: `capture.lockDenied` goes true and `onLockDenied` fires. Show "click to play" then. - `engine.get('input')` is typed with no cast; the capture and state layers stay engine-free for a harness or a recorder. # FILE: packages/presentation/README.md # Presentation ## What Pure settings validation and match transport contracts for the portable player. No browser renderer or guest SDK API is added here. ## When to use Use this contract before allocating player resources or publishing a bundle. ## Install ```sh npm install gameable ``` ## Minimal example ```ts import { parseSettings } from 'gameable/presentation'; const settings = parseSettings({ features: {}, world: {}, physics: {}, maxPlayers: 1, sendHz: 20, }); console.log(settings.maxPlayers); ``` ## API `parseSettings(value, assetIds?)` validates and copies JSON data. Legacy host-only settings omit `schemaVersion`; authored `ui`, `gameplay` and `scene` require settings schema version 1 (independent of bundle schema version). All option objects are closed: stale fields, callbacks and arbitrary renderer code URLs are rejected. `ui.modules` optionally declares built-in module IDs with version 1; duplicate IDs, including multiple HUD writers, are rejected. Each keyed UI option schema also has a fixed built-in renderer contract; renderer versions default to 1. `raceCandy` is the engine-owned gameplay/scene theme; the shell is `gameable`. Character change policy is `lobby-unready-swap`. The game root's `settings.json` is read by bundle and direct serve. Its features, world, camera, seats and send rate must match `src/game.ts`. Gameplay settings must also be embedded in the existing definition's `rules.gameplay`, so the CLI can compare guest module data without adding a guest API. Character IDs must appear in the definition's assets and in the bundle asset manifest. Gameplay characters/spawns are seat-sized; floors/movers/checkpoints respect entity capacity. Match deadlines, checkpoint order, shape dimensions and unit rotations are validated before resource allocation. `definitionSettings` derives legacy host settings. `assertDefinitionSettings` rejects authored host/guest drift. `MatchModel` retains the authority's phase, round, revision, roster and private sequenced cue history. Ready is null, Loaded is `{ round }`, Freeze is `{ frozen }`; the existing race message names and 8/64/32-byte limits remain unchanged. Model revision must change when reused roster/event arrays mutate; renderers consume authority placement and readiness. ## Gotchas Authored physics cannot choose `wasmUrl`. A legacy `src/physicsOptions.ts` may choose that host URL, but it is stripped before serialization. If both physics sources exist their validated data must match. Scalar world gravity becomes `[0, gravity, 0]`; vector world gravity is retained. Physics settings and world gravity must agree. There is no runtime guest settings fetch: import settings into the script at build time. These contracts select future engine-owned modules; this package does not yet mount their drawings or run their gameplay. # 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` / `vfx` 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`, `hit`, `recover`: see `docs/character-physics.md`), `vfx` (`burst`, `start`, `move`, `stop`, `post`: declare `features: { vfx: true }`; see [Add effects](../../docs/recipes/add-effects.md)), `playtest` (below). - **`playtest`** — marks for playtests: `death(cause)`, `win()`, `goal(position)`, `reward`, `hazard(entity)` (what kills on any touch: the playtest route goes around it). See `tell-a-playtest-what-happened.md`. - **`ctx.music`**, `ctx.audio.playOnBeat`, `fx`, `occlude`: see `llms-audio.txt`. - **`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. ### UI Menus, cards, drag and drop, text entry: `ctx.ui`. See [Game screens and UI](../../docs/concepts/ui.md). ## 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?, maxShDegree? })` → `SplatAsset { geometry, count, shDegree, boundingSphere, format, url }`. `parseSplat(buffer, url, format?, options?)` for bytes you already have, `sniffSplatFormat(url, bytes)` for the decision on its own. SOG (`.sog`, or a folder's `meta.json`) included. `createSplatObject(asset, { autoSort?, colorSpace?, environmentLighting?, receiveShadows? })` → a splat object with bounds computed and `renderOrder` set. `registerGltfSplatExtension(gltfLoader)` teaches a `GLTFLoader` `KHR_gaussian_splatting`. **Places.** `loadSplatPlace(renderer, lodMetaUrl, { budget? })` → `SplatPlace`: a tiled bundle with levels of detail, streamed by camera within a budget (6 M desktop, 1.5 M phone); add `object3D`. **Dynamic.** `createAnimatedSplat(renderer, { capacity, boundingSphere?, colorSpace?, kernel?, pointShape?, shDegree?, renderOrder? })` → `AnimatedSplat`, which implements `SplatSink`: `capacity`, `allocate(count) → SlotRange`, `free(range)`, `buffers`, `nodes`, `storageCapacity`, `markGaussiansChanged()`, `setBoundingSphere(center, radius)`, `object3D`, `restPoints()`. Plus `clearSlots(range?)`, `largestFree`, `shBuffers`, `releaseCpuCopies()` and `dispose()`. `createSlotAllocator(capacity)` is the allocator on its own. `createWebglSkinnedSplat(renderer, { capacity, colorSpace?, pointShape? })` is the WebGL fallback's dynamic splat fed by a gaussian source and the owner's order (fork edits 28 and 29), the option beside the TSL path. **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. Where an sRGB splat's points add up is the fork's `setBlendSpace('pass' | 'file' | 'linear')` (edits 20 and 22): `'pass'` (the default) draws it only in 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); `'file'` is for the engine's splat layer (`createEngine`'s `renderer.splatBlend`, `'characters'` by default: the layer takes the characters the bridge flags, and every sRGB splat with `'studio'`; `'pass'` hooks the sRGB pass instead; `'linear'` blends in linear light); `'linear'` adds the decoded colours up in linear light wherever the splat is drawn. `colorSpace: 'linear'` is for a splat trained in linear light; it draws in your render directly. **Point shape.** `pointShape` (fork edits 18, 21, 26, 27): `'engine'` (three's point, the default), `'studio'` (the Gameable studio's kernel, `kernel: 'studio'` names the same), `'full-opacity'`, `'size-aware'` (every character's look since 30 Sep 2026: the engine's point while small on screen, the studio's once large, `setSizeAwareBand(lo, hi)`), `'trained'` (the align lab's measuring switch only). `setPointShape`, `setPointHook` (a per-point hook for a game's effects), `ensureRestPoints`, `copyDrawingFrom` are on the fork object (`sink.splat`). **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), `addBuilt(id, options?, place?)` (the same, its drawing built off the frame before it shows, so a place's first sight never freezes) and `added`. **Shadows.** `createSplatShadows(renderer, scene, { quality?, characterBias?, placeBias? })`: `addCharacter`, `addPlace(splat, { collider?, panorama?, lightProbes? })`, `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{SH,Panorama,Surfels}`, `panoramaFromGaussians`) are exported too. `addCaster(object)` / `removeCaster(object)` let any mesh cast (a prop, a door, a mesh character), skinned or not, as it moves; instanced meshes are skipped. A splat prop casts too: `addCaster(splat)` builds a mesh once from its opaque gaussians (5 cm cubes, `casterGeometryFromGaussians`) and draws that, and the splat stops receiving these shadows, as the cubes would shade it. A whole place would make too large a mesh: it warns and does not cast. **Shadow sources.** Spots, suns and points placed and aimed by hand (the bake editor's **Shadow sources**), in the splat's own space. They add no light: what stands in the place (characters' rig meshes, meshes given to `addCaster`) darkens it as if blocking each one. `addPlace(splat, { sources })` shades that place from its sources instead of the key light read from the scene; contact shadows under the feet stay, and the characters' own shadow follows the first sun or spot (`info.route` is `'shadow source'`). A spot is a cone (`angle`, `range`), a point is six faces (`range`), a sun frames everything that casts; each has a `strength` (how dark) and a `softness` (how wide the edge). They share one depth atlas of at most 16 tiles (a spot or sun 1, a point 6; `MAX_SHADOW_TILES`, at most 8 sources), drawn again only when something moved; `info.shadowSources` and `info.sourceTiles` say what is live. The file is `.shadows.json` (`readShadowSources`, `writeShadowSources`); name it in the splat's `assets.json` entry as `"shadowSources"`, and the splat module reads it and the character bridge passes it on by itself (`shadowSourcesOf(object)` for your own system). The place's gaussians read every tile that reaches them, so overlapping sources cost more (more than two live tiles read the smaller filter). Fork edit 43. The shade is read once a gaussian (or at each corner of its square with `placeSpread`), so on a capture whose floor is made of gaussians as large as a figure's shadow the shadow comes out partial and soft: on the basement test capture a sun at strength 0.75 kept 13 % of the light at the shadow's core but 82 % on average over it (28 Oct 2026). Raise `strength` for a clearer one. **Light probes.** A grid of points over a place, each holding the light that reaches it from the place's own gaussians (nine RGB SH coefficients). `bakeLightProbeGrid(cloud, layout, options)` bakes one: at each probe the nearest surface in every direction (`panoramaFromGaussians`, so a wall hides what is behind it), projected with `shFromPanorama`. The bake editor's **Light probes** writes `.probes.json` (`readLightProbeGrid`, `writeLightProbeGrid`; the proof-of-concept app's files read too); name it in the splat's `assets.json` entry as `"lightProbes"` and the splat module reads it (`lightProbesOf(object)`). The character bridge then lights each exported character from the grid it stands in, every frame, with no rebuild: `AnimatedGaussianSplat.setEnvironmentProbe(radianceSH)` (fork edit 44). `createPlaceLightProbes(grid).at(placeMatrixWorld, x, y, z, out)` reads one where the place stands, the light turned with it (`rotateLightProbeSH`). On the basement test capture (1.6M gaussians) 380 probes at 1 m baked in 7.9 s on the page's main thread (29 Oct 2026). **Meshes in a light probe grid.** The same grid lights the scene's triangle meshes (props, weapons, level meshes, skinned characters): every lit node material (standard, physical, Lambert, Phong) reads it per pixel, on WebGPU and on the WebGL fallback. The splat module does it for each place it adds whose entry names `lightProbes`; `splat({ meshLightProbes: false })` turns it off. Inside a grid its light stands in for the scene's sky and fill (`HemisphereLight`, `AmbientLight`, three's `LightProbe`); suns, lamps and `scene.environment` are unchanged. At a grid's edge it fades out over one spacing across, and from 1 m to 2 m above the top layer or below the bottom one. At most `MAX_MESH_PROBE_GRIDS` (4) places light meshes at once. Without the module: `createMeshLightProbes(renderer, scene).addPlace(place, grid)` (it adds one `GridProbeLight` to the scene, which every lit material then takes). **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 - **A thing made of points that moves inside a place made of points** is a part of the place's object, not an object of its own: `createSplatParts(placeAsset, [count, ...])`, then `parts.fill`, `parts.show`, `parts.move(part, matrix)` and `parts.flush(renderer)` once a frame. WebGPU only; `engine.get('splat').add` takes the grown asset itself. - **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. The other way, an owner that skins in the vertex stage and sorts itself, is `createWebglSkinnedSplat`: its owner builds each gaussian in the vertex stage (fork edit 28, `setGaussianSource`) or writes the four buffers itself by transform feedback (`attributes()`, `order()`) and hands in the order it sorted (edit 29, `setSortOrder(order, camera, textureOnly?)`). Headset switches: `setExtentShare` (edit 39), `setMinimumContribution` (edit 40), `setFootprintCapPx` and `setDrawShape` (edit 41), `lowResDraw()` (a splat drawn small, scaled up: `attachLowResDraw`). 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. The other lights a mesh is drawn with work the same way, up to four of each: `directionalLights` (the first is the key: a splat's shadow darkens its share alone), `spotLights` (three's cone, softened like the lamps), `hemisphereLights` and `ambientLights`. Each adds irradiance as three's own light node does, so at `diffuseWeight: 1` a light brightens a splat as much as a white `MeshStandardMaterial`: hand a scene's sky and sun to its splat and both answer to them. `wrap` (0 to 1) lets the far lights, lamps and spots reach past the terminator. `setLightingWeights({ emissionWeight, diffuseWeight, wrap })` changes the mix live, any subset, with no shader rebuild: fade a place's captured light out and the scene's lights in on a slider or every frame. It throws before `setEnvironmentLighting`. `examples/splat-viewer/?light=day` is a live demo. **Baked lighting.** A place that does not move, under lights that do not move, can have its re- or de-lighting worked out once and drawn with one multiply a gaussian (fork edit 42). Light it in the bake editor (`examples/splat-bake`), write `arena.light.bin` beside `arena.spz`, and name it in the entry; the splat module fetches it with the splat and `add` draws it, with no code: ```json { "id": "arena", "type": "splat", "src": "arena.spz", "bakedLighting": "arena.light.bin" } ``` The file is 3 bytes a gaussian (a log2 byte a channel), gzipped, and 4 bytes a gaussian on the card. It holds the splat's fingerprint (`splatFingerprint`: the count and every centre): a bake for another version of the splat (re-exported, retrained, reordered) is not drawn, and the console says why. In code: `bakeSplatLighting(asset, recipe)` gives the multipliers, `encodeBakedLighting` and `writeBakedLighting` make the file, `readBakedLighting` reads it, `createSplatObject(asset, { bakedLighting })` draws it, and `recipeToEnvironmentLighting(recipe)` lights the splat live from the same recipe. No splat shadows are baked, and a streaming place (`useProjectedDraw`) cannot draw a bake. # 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.check` — `'error'` (default), `'warn'` or `'off'`: `gameable check` on production builds. - `options.exclude` — extra packages kept out of the dependency pre-bundle. - Dev, direct mode: a broken edit to a game source keeps the game running (`GAMEABLE_RELOAD_KEPT_OLD_CODE`). - `options.rootPaths` — `'error'` on `--base ./` builds (else `'warn'`), or `'off'`: a leading-`/` address fails. - `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 @getgameable/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('@getgameable/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, the same SDK guest runtime in the host's own realm with no build step (what `npm run dev` uses); a parity test hashes both modes. ## 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)), getCoreBytes: (p) => readFile(new URL(p, guestDir)).then((b) => new Uint8Array(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, getCoreBytes, 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`: the entity-to-`Object3D` mapping, the transform pairs the renderer interpolates between, assets resolved to splats and glTF scenes, a placeholder mesh the shape of an entity's physics body until a model exists. Host modules are reached through `engine.get(...)`; leave one out and its commands are ignored with one warning. `adapter.update(dt, alpha)` is one rendered frame. - **`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 and its look: `mouth`, `corrective`, `mouthMesh`, `mouthTextureSize`, `mouthLook`, `sharedClips`, `keepSharedFiles`, `pointShape`, `blend`, `kernel`, `pointHooks`), `deform` (how an export's points follow its bones: `'tsl'`, the default on both backends; `'wgsl'`, the WebGPU compute path; `'webgl-skin'`, the vertex-stage skin sorted in a worker), `fetch`, `loadGltf`, `shadows`, `speech`, `onLoadFailed`, `warn`, `log`, `loading`, `buildAhead`, `webglFallback`, `life`, `ownIdle`, `locomotion`, `ragdoll`. The bridge also has `upgrade(entity, src, { fadeMs })` (a fuller copy of an exported character swapped in), `shadows`, `loading`, `clipEnds`, `jointOf`, `attach`, `seat`, `hit`, `fall`, `recover` and `entryOf(entity)` (with `mouth`, `corrective`, `hiddenPoints`, `runtime`, `splat`, `measures`, `life`, `seated`, `animation` and `ragdoll` for an export). On the WebGL fallback exported characters still draw (the TSL passes as transform feedback by default, the fallback skin with `deform: 'webgl-skin'`; no mouth interior and no shadows there; `webglFallback: false` keeps the placeholder); GNM-rig and decoder characters degrade to placeholders with one warning, never a throw. `shadows` (default `'auto'`, a level, or `false`): characters cast onto places and places onto characters, from a key light read from the scene; `bridge.shadows` changes the level and says which source chose the light. `bridge.jointOf(entity, 'head' | 'root' | name)` is a joint's world position and turn as posed now; `bridge.attach(entity, joint, object, offset?)` hangs a weapon or a hat on a joint; `bridge.seat(entity, { seat, hands, feet, floor, refresh })` sits a character on objects in the scene (a vehicle's seat, grips and foot rests, in `createSeatedPose`'s pose) and `bridge.seat(entity, null)` stands it up again; its physics body is out while it sits; `bridge.clipEnds` are the clips that ended since the loop last drained them into `anim-event`s. `loading` (on by default): every package's files go through one ordered, counted fetch, the first character first (`order`, else the spawn order), a character's light copy (`rig.light`) before its full one (`fullCopies: false` on a phone stops there), the place after the first character (`bridge.loading.placeTurn()`); `onProgress` says bytes and files, per character and in all. `buildAhead` (on by default) builds a character's drawing before it is called ready, so its first sight never freezes. `ragdoll` (on by default with the physics module): every exported character's parts as bodies on the physics; `bridge.hit(entity, { at, direction, strength })`, `bridge.fall`, `bridge.recover`; the events `hit` / `fell` / `recovered` on the clip `ragdoll`. See `docs/character-physics.md`. - **`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. One fixed step is one guest tick: commit transforms, read input, contacts and the clips' ends, encode, `tick`, `applyOutput`. The post-step body rows arrive on the `physics:stepped` event and go straight onto the entities they drive (`adapter.applyBodyRows`). A dead sandbox stops the loop and raises an overlay. - **The editor** — `createHostLoop` makes an `Editor` and publishes it as `window.__gameable.editor` (`editorOf()`; turn it off with `{ editor: false }`). `enter()` pauses the game (time scale 0), suspends the input module, and puts an orbit camera and a transform gizmo on the page; `actors()` lists the guest's actors from a snapshot's header (`peekSnapshotHeader`): class, name, props, tags, whether a level placed it (`editable`, its `source`) and where the page draws it; `pick(ndcX, ndcY)` raycasts the scene, `select(id)` attaches the gizmo, `setTransform` moves from numbers, `groundPoint` finds the surface under a point, `showColliders` draws the bodies, `levels()` and `loadLevel(name)` read and ask for the game's levels. Events: `selected`, `changed` (position, rotation, yaw and scale as the gizmo drags, and `what` names the parts the edit moved, so a turn is not written as a move), `contextmenu` (page pixels and the actor under the pointer), `missed` (a click on something that is no actor, with the guest entity it belongs to, 0 for the page's own), `levelRequested`. While editing, W A S D and the arrow keys fly the orbit camera along the view, Q and E (Page Down, Page Up) along the world's up, Shift faster. A move takes the actor whole, its root and its components, to the transform store and the bodies as a teleport, which holds because a paused loop runs no step; it is a preview, and `exit()` puts everything back. Whoever drives the editor keeps a layout by writing the level file (`applyLevelEdits`, `levelDocumentOf` in the SDK) and reloading the game. - **Playtests** — `createHostLoop` makes a `PlaytestController` and publishes it as `window.__gameable.playtest` (`playtestOf()`; turn it off with `{ playtest: false }`). It follows the player's body, notices falls, respawns, stuck players and missed jumps, and keeps the game's `playtest` marks. What the agent sees is taken from the camera's heading: the body's velocity and footing, rays for walls, a height map of the ground around it (a 5 × 9 grid cast down from above its head, so gaps ahead and ledges above it both show), the goal's bearing, the next waypoint of a route to the goal, and the bearing of the way the run first faced and the distance back to its start (so a respawn facing the wrong way is not a mystery). The route: once a kept run has a footing and the game has said the goal, the level between them is scanned from above in one batch of rays (`LevelMap`, at most 128 × 128 cells), and `Route` plans walks and jumps over it, with jumps as long as the player has shown (3 m and 1.2 m up before any). A goal it cannot reach is a `no-route` finding that says where the gap is. It sees top surfaces only, and a moving platform where it was at the scan; the height map sees it move. `record()` keeps a person's run as a `Trace` (observations, actions, raw input); `train()` teaches a small policy from those runs in the page (behaviour cloning, no dependency); `play()` and `run({ episodes })` let it play, each life after the first starting again at the run's start or the last `playtest.checkpoint` the game marked (the player is teleported, a `restarted` event says so); `practice({ rounds })` lets it get better by playing: each round it plays with room to explore, scores every step (the game's rewards, wins and reaching the goal, deaths, metres gained along the route) and learns its own steps that went better than usual again (self-imitation), with the person's runs alongside; every round begins where the first did, a practised policy replaces the one it began with only when it scores clearly better, and none of practice's runs is kept; `pilot({ episodes })` lets the route pilot play: no recording and no policy, it walks the route to the goal, steps sideways toward each waypoint, runs (sprint held), lines up along the edge with a landing off to one side, and jumps at the edge where the route jumps (or onto something close ahead at its feet), waiting at an edge for a moving platform; its runs are kept as kind `pilot`, say where a player who follows the route falls short, and are what `train()` and `practice()` learn from when no person has recorded a run; `movement()` is how the player has been seen to move (walk and run speed, jump speed, gravity, measured from play) and `jumps()` judges every jump on the route against it: across and up, what a walking and a running jump carry there (`carry`, ballistic), what it needs, the reach left over, and the jump speed or gap that would leave a 30 % margin (`jumpSpeedFor`: the tune lab's rules run backwards to the setting a result wants); the route plans only jumps within 90 % of a jump's reach, rides a platform seen to move only when nothing else gets there, never steps or lands on a hazard the game said (`playtest.hazard`: spikes, enemies, followed by their body) and jumps a row of them it cannot go around, and scans a new level once the game has built it; `assess(runs)` reads summaries by who played them (`pilot`, `agent`, `human`) and says whether the game can be finished (a level is possible when someone reached its goal, impossible when the route has no way there), how hard each level is (attempts per clear) and what in its pacing tends to spoil the fun (a spike where most deaths happen, nothing to collect, too easy, punishing, stuck players, levels that get easier); a summary splits play by each goal the game says (`goals`, a level each) and says its `pace`; `replay(trace)` plays a run's input back; `summary()` sums up where players died and got stuck. `encodeTrace` / `decodeTrace` / `gzip` move traces, `Policy.toJSON` / `fromJSON` policies. Observation layout 3: traces and policies of layout 2 are refused; record again. - **`createGameSlot(order?)`** — books the game module's place in the module order (`createEngine` wants its list before the engine exists); `slot.attach(loop, engine.ctx)` fills it in. - **`createDomHud(options?)`** — the default HUD renderer: `{ text, bars, crosshair, message }` into an overlay `div`, diffed. - **`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). - **`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. After its first frame a HostLoop dispatches `gameable:ready` on window, once. ## 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.** 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, which 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, deliberately.** `gameable/host/characters` keeps `onnxruntime-web` out of a game with no characters; imported dynamically 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 ``` Wasm sandboxes require `getCoreBytes` while a memory limit applies (512 MiB by default). The host clamps each core before compilation and checks aggregate memory after every call. Only trusted built-in guests may use compiled-only `getCoreModule` with `memoryLimitBytes: null`; allocator failures still latch the sandbox dead. # FILE: packages/world/README.md # gameable/world ## What A world package (one folder with a `world.json`: [the format](../../docs/concepts/worlds.md)) loaded and made playable. `world()` is the `EngineModule` that puts one in the engine: the place as points or as a mesh (or a plain white world for a thing made by itself), its collision as one solid body, the sky from its panorama, its light, every loose thing drawn where the package placed it, as its model or as its points, as a body that can be pushed, the place's loop and each thing's own sound on a real knock. It keeps the list of things up to date as they move and answers what a page, a character or game code asks: find by words, what is near, where to stand. ## When to use Your game's world was made rather than modelled: a maker turned a picture or a sentence into a package and the game should be playable in it at once. For a bare capture with no things in it, [`gameable/splat`](../splat/README.md) and a collider are enough. ## 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'; import { createEngine } from 'gameable/core'; import { physics } from 'gameable/physics'; import { splat } from 'gameable/splat'; import { world } from 'gameable/world'; const engine = await createEngine({ canvas, manifest, modules: [physics(), audio(), splat(), world()], }); engine.start(); const made = await engine.get('world').load('worlds/forgotten-attic/'); const trunk = engine.get('world').find('the trunk'); if (trunk) console.log(made.list.name, trunk.position, engine.get('world').standFor(trunk)); ``` Game code asks the same with no call to the host, through the SDK's `things` facade, once `createWorldBridge` is wired ([the recipe](../../docs/recipes/load-a-made-world.md) shows it). ## API - `world(options?)` — the module; `id` is `'world'`, `order` 10 (after physics). `settle: true` lets things drop on load; `motion` tunes how a knock is told. - Service, `engine.get('world')`: `load(address, { lite, thingCopies, placeForm, thingsAs, maxTextureSize, roomVolume, onProgress })` resolves when the world is playable (`complete` when the sounds are in too), `drawThingsAs('mesh' | 'splat' | 'record')` and `thingsAs` (the loose things as their models, as their points, or each as the package says; a form's files come in the first time it is asked for), `unload()`, `current`, `things`, `find(words)`, `near(point, radius)`, `standFor(thing, from?)`, `limit(x, z, velocity)`, `push(thing, impulse)`, `move(thing, point)`, `reset(thing?)`, `labels` (each thing's name and box, a page's switch), `lightGain`, `onNotice(listener)`, `breakThing(thing, { parts, kick })` (a thing in pieces). For a thing with moving parts: `setJoint(thing, joint, value)` and `jointOf(thing, joint)` (degrees for a hinge, held to the joint's limits); for one that drives (a `motorbike` or a `monowheel`): `drive(thing, throttle, right, brake)`, `speedOf(thing)` and `rideSpeed` (its top speed); for one with rider spots: `rideOf(thing)` (its spots as objects, in the shape the character bridge's `seat` takes) and `present(alpha)` (the things' drawing put where it stands this frame, for a reader ahead of the module's own update). - `jointMatrix(joint, value, out)`, `orderParts(parts)`, `axleRise` and `travelFor` — a joint's move as a matrix, the parts parent first, and a wheel's rise turned into its arm's angle and back. `spotFrame(spot)` and `jointsOver(parts, joints, part)` — a rider spot's own frame, and the joints that move a part. - `current.light` — the place's suns and surrounding light in the shape the character bridge's `lighting.set` takes. - `current.points` — how many loose things come as points, how many points that is, and `inPlace`: whether they are drawn inside the place's own points. - `createWorldBridge({ world, events, characters? })` — the world to game code: `world-event`s (`loaded` with the list, `hit`, `moved`, `unloaded`, `joint`, `broken`, `mended`) in, `thing` commands (`push`, `move`, `reset`, `reset-all`, `joint`, `drive`, `ride`, `break`) out. With the character bridge as `characters`, `ride` sits the character the game names on the thing's rider spots (`things.getOn`) and takes it off (`things.getOff`). - `readWorld(json)` — the record, checked, defaults filled; throws `WorldFormatError` in plain words. `worldListOf(record)` — the list. `worldAssets(record)` / `worldAssetId(world, thing, part)` — the files as asset ids (`forgotten-attic.steamer-trunk.mesh`). - `placeTransformOf`, `applyFileTransform`, `heightsToMesh`, `createWorldLights`, `createWorldLabels`, `createMotionTracker`, `materialOf`, `plainWorldOf`, `createPlainWorld` (the plain world of a thing by itself: its floor, fog and shadow) — the parts, for a host that builds its own loader. - `WORLD_BODY_BASE` — the place's body id; thing `i` is that plus `1 + i`. Headless hosts (rooms, `bootHeadlessGame`) load a made world's static collision from `gameable/world/headless`, the same record and transforms as the page. ## Gotchas - **A thing rests asleep where the package put it** until something touches it, so it is found as it was placed, even where the place's collision has no surface under it. Pass `world({ settle: true })` to let them drop. - **A knock is read from a thing's own motion**, not from the physics contacts (the game's loop drains those): a sudden change of velocity sounds, a push along the floor does not. After a thing sounds, only a harder knock sounds again for a second or so, and the whole world keeps to four a second. - **Things take the lighter copy by default** (`thing.lite.glb`); the place takes its lighter copy only with `lite: true` (a phone). - **A thing by itself** (`kind: "object"`) is taken in full without `lite`. The module turns the renderer's shadow maps on for its floor; a texture larger than the graphics card takes is made that size on arrival and the console says so. `current.plain` and `current.copies` say what loaded. - **A mesh is measured, not trusted**: its bounds after its nodes' transforms are fitted to the record's `size`, so a file in other units or with its origin elsewhere still stands where the record says. - **A thing as points is drawn inside the place's own points** ([the format](../../docs/concepts/worlds.md#a-thing-as-points)) only with `forms.splat.points` in the record. In a place drawn as a mesh its points are an object of their own, drawn after the place: right there. - **A thing's points carry their light** (`forms.splat.light: "place"`): the engine draws them as they are, so a thing knocked over keeps the light it stood in. - **The module needs a renderer.** `readWorld`, `worldListOf` and the SDK's questions are pure and run in node. ## See also - [Worlds](../../docs/concepts/worlds.md) — the package's format - [Load a made world](../../docs/recipes/load-a-made-world.md) - `examples/world-walk` — open a package, walk in it, push its things # 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. - [Walk with character movement](./walk-with-character-movement.md) — guest-owned walking capsules, blocking NPCs and tile walls. - [Backpedal with a held facing](./backpedal-with-held-facing.md) — retreat facing an opponent. - [Click in the world](./click-in-the-world.md). - [Read player input](./read-player-input.md) — named actions, not key codes. - [Build a game screen](./build-a-game-screen.md) — menus, answer grids and draggable cards, with state-driven UI updates. - [Add effects](./add-effects.md) — hit sparks, attached fire, bloom and fog through the VFX facade. - [Add a HUD element](./add-a-hud-element.md) — a value on screen that only crosses the boundary on the frames it changed. - [Tell a playtest what happened](./tell-a-playtest-what-happened.md) — mark deaths, the goal and the win, so the agent can learn the game and the reports say where players die. - [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. ## Gameplay building blocks Small guest parts with a shared reset, snapshot and restore lifecycle. - [Count down with a timer](./count-down-with-a-timer.md) — fixed-step countdowns. - [Ease a value with a tween](./ease-a-value-with-a-tween.md) — named numeric lanes. - [React when something enters a zone](./react-when-something-enters-a-zone.md) — guest geometry. - [Deal damage and heal](./deal-damage-and-heal.md) — Health, death and invulnerability. - [Give the player an inventory](./give-the-player-an-inventory.md) — stacks and overflow. - [Add an ability with a cooldown](./add-an-ability-with-a-cooldown.md) — charges and use gates. - [Track a quest](./track-a-quest.md) — objectives and prerequisites. - [Spawn enemies in waves](./spawn-enemies-in-waves.md) — Spawner and TargetPoints. - [Unit orders](./click-to-move-and-orders.md). - [Take turns](./take-turns.md) — authority validation, timeouts and wake catch-up. ## Actors and level files Start these recipes from `templates/blank`; use premade actors before writing custom systems. [Actors](../concepts/actors.md) explains the lifecycle. - [Place actors from a level file](./place-actors-from-a-level-file.md) — placements as data. - [Switch levels at runtime](./switch-levels-at-runtime.md) — several level files. - [Move on a grid](./move-on-a-grid.md) — tile stepping, blocking NPCs and bumps. - [Make your own actor](./make-your-own-actor.md) — components, props and hooks. - [Add a pickup](./add-a-pickup.md) — rewards and respawning. - [Pick the right door](./pick-the-right-door.md) — doors, keys and switches. - [Add a door that needs a key](./add-a-door-that-needs-a-key.md) — inventory and interaction. - [Add a checkpoint and kill zone](./add-a-checkpoint-and-kill-zone.md) — no custom system. - [Run a match with a game mode](./run-a-match-with-a-game-mode.md) — rules and scores as data. - [Spawn enemies in waves](./spawn-enemies-in-waves.md) — Spawner and TargetPoints. ## 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. - [Make enemies chase around obstacles](./make-enemies-chase-around-obstacles.md) — paths around crates, crowd steering and an interruptible behaviour tree. - [Change the level](./change-the-level.md) — your own splat environment, your own collider, your own spawns. ## Third-person - [Side-scroller](./side-scrolling-platformer.md). 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. - [Set up talking characters](./set-up-talking-characters.md) — the relay that keeps the API key on the dev server, and the page's voice, face and talk box. - [Write a character's story](./write-a-characters-story.md) — a talking character's persona, voice, knowledge and objectives, for Convorcher. - [Talk to a character](./talk-to-a-character.md) — a talking character (AI NPC, Convorcher) the player types or speaks to in their own words, with voice, subtitles and a moving face. - [Let the game decide a conversation](./let-the-game-decide-a-conversation.md) — a gatekeeper the player must convince: the game owns permission, refusal and retry, and a slow reply never costs an attempt. - [Tune the follow camera](./tune-the-follow-camera.md) — boom length, height, pitch range, shoulder offset and collision. - [Top-down camera](./top-down-camera.md). - [Add a locomotion state](./add-a-locomotion-state.md) — a crouch on `Ctrl` at half walk speed, reported to the animator. - [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. ## Race Obstacle recipes build on `templates/race`; the vehicle recipe starts from `templates/blank`. - [Racing and vehicles](./racing-and-vehicles.md) — cars, lap gates and a CPU rival. - [Off-road time trial](./off-road-time-trial.md). - [Recover a stuck racer](./recover-a-stuck-racer.md) — a racer wedged for twelve seconds goes back to their checkpoint, and every teleport starts the clock again. - [Place finishers by when they crossed](./place-finishers-by-when-they-crossed.md) — two racers over the line on the same step are placed by which crossed first within it, not by seat. ## 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. - [Load a made world](./load-a-made-world.md) — a world package in place of the arena: the place, its things as bodies with their sounds, and their names. - [Make a swinging door](./make-a-swinging-door.md) — push a hinge and drive it with a motor. - [Build a physics puzzle](./build-a-physics-puzzle.md) — lever, slider gate and swinging weight. - [Cast a shape](./cast-a-shape.md) — throw clearance, teleport checks and pressure plates. - [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. - [Play music that changes in combat](./play-music-that-changes-in-combat.md) — stems that bring drums in on the next bar when a fight starts, and a crossfade to another track. - [Trigger on the beat](./trigger-on-the-beat.md) — react to every beat, schedule a sound exactly on the next one, and judge a press by how close to the beat it was. ## 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. - [Build a fighting game](./build-a-fighting-game.md) — the fight pack, the pose layer, the rag-doll and the stagger, the fight camera and your own pose on the bones: the fight kit Fight Lab plays on. - [VR room](./vr-room.md). ## 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 - [Start a game](./start-a-game.md) — one Play action for people, capture and playtest. - [Platformer](./build-a-platformer.md). - [Survival horror](./survival-horror.md). # 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 - [Build a game screen](./build-a-game-screen.md): menus and cards. - [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-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 For new games, start with an actor and its `interactable()` component; see [make your own actor](./make-your-own-actor.md). 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/quest.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/quest.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/quest.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/level.json` 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 > Scripted lines with fixed choices. For a character the player talks to in their own words (an AI NPC with a > persona and a voice, on Convorcher), see [Talk to a character](./talk-to-a-character.md) and the > `llms-talking-character.txt` bundle. ## 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/level.json` 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, getCoreBytes, 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/level.json` ## 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` (the file `public/levels/foundry.spz`) loads there. A world baked in `examples/splat-bake` has a `.light.bin`: add `"bakedLighting": "levels/foundry.light.bin"` to the entry. Then drop the arena's `environmentLighting` in `src/main.ts`, or it is lit twice. 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. Say which place the level plays in, and move the placements. `place` is the manifest id of the world; every `position` is in metres, Y-up, with `yaw` in degrees about `+Y` (`0` faces `+Z`). A level actor's name is how the sandbox's edit mode finds it, so name what people will move. ```diff // src/level.json { "format": 1, + "place": "env.arena", "actors": [ { "name": "playerStart", - "position": [0, 1.15, 9], + "position": [-17, 1.15, 0], + "yaw": 90, "class": "PlayerStart", "tags": ["arena"] }, { "name": "guide", - "position": [2.5, 1.05, 4], + "position": [12, 1.05, -6], "yaw": -26.562, "class": "Npc", ``` The level is loaded before `init`, so adding or removing an entry changes how many things exist. Nothing else needs to know. A game with several places keeps one level file per place: [switch levels at runtime](./switch-levels-at-runtime.md). ## Verify ```sh npm test ``` The template's tests read placements from `src/level.json` through `tests/level.ts`, so a moved start moves the test with it. 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-made-world.md # Load a made world ## Goal A world package (a folder with `world.json`) replaces the template arena. The hero stands at the world's start; loose things can be pushed and make sounds; game code can find things by name. ## Files you will edit - `src/main.ts` - `src/systems/world.ts` ## Steps 1. Put the package in `public/worlds/forgotten-attic/` and name it in `src/main.ts`. The third-person template wires the world module to the game; add its address: ```ts const WORLD_URL = 'worlds/forgotten-attic/'; ``` Write the address without a leading slash; the engine also accepts `/worlds//` and reads it from the game's folder. For custom URLs, use `gameUrl()` from `gameable/assets`. The template reads `VITE_WORLD` from `.env.local`, so the address can stay out of code. With it set, the host loads the world instead of the arena: ```ts import { createWorldBridge, world, type WorldBridge } from 'gameable/world'; // modules: [physics(), input(), audio(), splat(), world(), ...] await engine.get('world').load(WORLD_URL); let worldBridge: WorldBridge | null = null; const adapter = createEngineAdapter(engine, { modules, characters, thing: (command) => worldBridge?.handle(command), }); worldBridge = createWorldBridge({ world: engine.get('world'), events: adapter.events }); ``` In the visit template the same package is one input: `?world=worlds/forgotten-attic/world.json` (or `world` in the lookup's JSON) in place of the place's own fields. 2. Build your level from the world's things in `src/systems/world.ts`. The system there already stands the hero at the world's start when the `loaded` event arrives and takes the arena's own props out. Ask the `things` facade for the rest; it answers inside the guest, with no call to the host: ```ts import { things, type GameContext } from 'gameable'; const heroAt = { x: 0, y: 0, z: 0 }; export function nearTheTrunk(ctx: GameContext): boolean { const trunk = things.find('the trunk'); if (trunk === null) return false; // Where to stand to use it, and what the hero is beside now. const spot = things.standFor(trunk); const beside = things.near(heroAt, 1.2)[0]; return beside === trunk || Math.hypot(spot.x - heroAt.x, spot.z - heroAt.z) < 0.5; } ``` Knocks and moves arrive as events, and a loose thing takes commands: ```ts export function hearTheWorld(ctx: GameContext): void { for (const event of ctx.events) { if (event.tag !== 'world-event' || event.val.kind !== 'hit') continue; const knocked = things.all[event.val.thing]; if (knocked.kind === 'bottle' && event.val.strength > 0.6) things.reset(knocked); } } ``` ## Verify ```sh npm run dev ``` The made world is on screen with the hero at its start; walking into a loose thing pushes it and plays its sound; the walls stop you. Then assert it headlessly, as `tests/world.test.ts` does: hand the game a `world-event` of kind `loaded` and check where the hero went. ```sh npm test ``` ```ts expect(move.val.position.x).toBeCloseTo(list.spawn.x); ``` To see every thing's name over it, open `examples/world-walk` on the same folder (`GAMEABLE_WORLDS_DIR= npm run dev -w examples/world-walk`) and switch Names on. A world whose loose things come as points too shows a second switch there, Things as points; in a game it is one call, `engine.get('world').drawThingsAs('splat')` (or `'mesh'`, or `'record'`: as the package says). A package of one thing and no place ([a thing by itself](../concepts/worlds.md#a-thing-by-itself)) loads by the same call, and `things.world.kind` is `'object'`. Pass `lite: true` on a phone for the thing's lighter copy: ```ts const made = await engine.get('world').load('worlds/pink-menace/', { lite: onAPhone }); console.log(made.plain, made.copies); // true, 'full' on a desk or 'lite' on a phone ``` ## See also - [Worlds](../concepts/worlds.md) — the package's format - `packages/world/README.md` — gameable/world - [Load a splat environment](./load-a-splat-environment.md) — a bare place with no things - [Add a physics body](./add-a-physics-body.md) # 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/move-on-a-grid.md # Move on a grid ## Goal Give a creature-RPG explorer tile stepping: a quick tap turns, holding walks, Shift runs, and walls or NPCs cause a bump. Start from `templates/blank`. ## Files you will edit - `src/game.ts` ## Steps Replace `src/game.ts` with the example below. Keep your template's settings. The grid component is the root body. Its default capsule extends 0.9 metres above and below the actor position, so place its centre at y = 0.9. `spawnTileColliders` addresses cells by their lower corner; origin (0.5, 0.5) aligns the walkers with those cell centres. ```ts import { box, defineActor, defineGame, gridInputFromKeys, gridMovement, mesh, requestGridMove, spawnActor, spawnTileColliders, } from 'gameable'; import settings from '../settings.json'; const Explorer = defineActor('Explorer', { components: { root: gridMovement({ origin: { x: 0.5, z: 0.5 }, onBump(self, tile, hitEntity) { // Feed an animation or sound system through saved state. self.state.bumpX = tile.x; self.state.bumpZ = tile.z; self.state.bumpEntity = hitEntity ?? -1; }, }), look: mesh('cube-mint', { scale: [0.5, 0.7, 0.5] }), }, tick(self, ctx) { const direction = gridInputFromKeys(ctx); if (direction) requestGridMove(self.entity, direction, ctx.input.isDown('Shift')); }, }); const Villager = defineActor('Villager', { components: { root: box([0.35, 0.9, 0.35], { collision: 'Pawn' }), look: mesh('cube-muted', { scale: [0.5, 0.7, 0.5] }), }, }); export default defineGame({ features: settings.features, world: settings.world, init(ctx) { gridInputFromKeys(ctx); // Allocate input history before gameplay. spawnTileColliders(ctx, [ { x: 2, z: 0, height: 2 }, { x: 2, z: 1, height: 2 }, { x: 2, z: 2, height: 2 }, ]); spawnActor(ctx, Explorer, { position: { x: 0.5, y: 0.9, z: 0.5 } }); spawnActor(ctx, Villager, { position: { x: 0.5, y: 0.9, z: 2.5 } }); ctx.camera.set({ x: 6, y: 10, z: 8 }, { x: 0, y: 0, z: 0, w: 1 }); ctx.camera.lookAt({ x: 1.5, y: 0, z: 1.5 }); }, }); ``` Include the placeholder pack in the template's asset manifest for `cube-mint` and `cube-muted`, as in [Use the placeholder assets](./use-the-placeholder-assets.md). The wall bodies are invisible; replace their look with your world artwork. NPCs using `gridMovement()` also reserve destinations, preventing two walkers from entering the same tile. Resting walkers keep their occupied tile blocked. Requests from actor ticks and user systems are consumed on the next component step. Repeat the request each tick while held. A fresh hold waits 0.08 seconds before stepping; continuing steps take 0.22 seconds each, or that duration divided by 1.8 when running. North is -Z. For eight-way movement, set `directions: 8` and call `gridInputFromKeys(ctx, 8)`; diagonal steps take the same time as cardinal steps and check their destination tile. They can pass between blocked corners. ## Verify Run `npm run dev`. Tap D briefly: turn east without changing tiles. Hold D: walk one tile, then bump against the wall at cell (2,0). Hold S to approach the villager at (0,2); bump without sliding. Hold Shift with a direction to run. ## See also - [Make your own actor](./make-your-own-actor.md) - [Use the placeholder assets](./use-the-placeholder-assets.md) - [Actors](../concepts/actors.md) # 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. Declared audio assets load automatically on first play. ```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 Declare named actions once; read them on keyboard, gamepad and touch. ## Files you will edit - `src/game.ts` — declare bindings and read input. ## Steps 1. Bind buttons and axes in `defineGame`: ```ts import { defineGame } from 'gameable'; const feedback = { fired: 0 }; export default defineGame({ actions: { fire: { keys: ['Space'], gamepad: [7], touch: 'tap' }, reload: { keys: ['R'], gamepad: [2], touch: 'button:RELOAD' }, move: { axis2: ['A', 'D', 'S', 'W'], stick: 'left', gamepadAxes: [0, 1] }, }, systems: [ (ctx) => { const move = ctx.input.axis2('move'); if (ctx.player) ctx.physics.moveCharacter(ctx.player, move.x * 5, 0, -move.y * 5); if (ctx.input.action('fire').pressed) { feedback.fired = ctx.frame; ctx.hud.set(feedback); } }, ], }); ``` Read pooled `{ down, pressed, released }` immediately. Any binding can supply an edge. Standard gamepad indices: RT 7, X 2. Touch: `tap`, `hold`, `doubleTap`, or `button: