# Gameable Engine — building a third-person adventure # Everything needed to write a third-person adventure with Gameable Engine, and nothing else. # Generated by tools/docs/gen-llms.mjs. Do not edit. # How to use these files A generated bundle of the Gameable Engine docs. Each file starts with `# FILE: `. 1. Read AGENTS.md, the first file below. Its numbered rules are enforced by ESLint and `npm run check`, not advice. 2. Copy templates/brawl or templates/collect or templates/fps or templates/hangout or templates/mystery or templates/steal or templates/survive or templates/third-person or templates/visit, or run `npm create gameable my-game -- --template brawl`. 3. Edit `src/game.ts` and `src/prefabs.ts` — plus `src/systems/*.ts` and `src/assets.json` when a recipe says so. **Never edit `packages/*`**: if a game needs an engine change, say so and stop. That is a missing feature. 4. Run `npm run dev`, open http://localhost:5173. A save is a reload; there is no build step. 5. For one task, ask for its recipe by name ("show me docs/recipes/add-a-weapon.md"). Each is Goal / Files you will edit / Steps / Verify / See also, and touches at most two files. docs/recipes/index.md lists them all. Assets are addressed by string id, never a path or URL. Nothing in a system allocates. Import `three/webgpu`, never bare `three`. # FILE: AGENTS.md # AGENTS.md ## What this repository is **Gameable Engine** (npm package `gameable`, scaffolder `create-gameable`, CLI `gameable`) is a browser game engine. Worlds are **gaussian splats** rendered by three.js r186's native `GaussianSplat` on a `WebGPURenderer`. **All game logic** is TypeScript compiled to a QuickJS **WebAssembly component**; bitecs 0.4 is the ECS and it lives inside that guest. The host owns rendering, Jolt physics, input, audio, assets and characters. ### Two names for every package One npm workspace: the engine is split into private workspace packages under `packages/*`, named `@gameable/` (`@gameable/core`, `@gameable/sdk`), and they are published together as one package, `gameable` (`packages/gameable`), whose subpaths re-export them (`gameable/core`, and `gameable` itself for the SDK). `packages/gameable/entries.mjs` is the table. - **Code inside `packages/*/src`** imports its siblings by workspace name: `import { createEngine } from '@gameable/core'`. - **Everything a user sees or copies** — templates, examples, fixtures, docs, READMEs, doc comments, `llms*.txt` — uses the public subpath: `import { createEngine } from 'gameable/core'`, and game code imports only from `gameable`. - A new export in a workspace package needs a row in `entries.mjs`; `npm run gen -w packages/gameable` writes the rest, and the test suite fails until it is there. ## Read this before writing a game If you are building a game, do **not** read the whole repository. Read one bundle: - `llms-fps.txt` — first-person shooter. - `llms-third-person.txt` — third-person adventure. - `llms-visit.txt` — a character's page. - `llms-multiplayer.txt` — rooms and Play Solo. - `llms-character.txt` — splat characters in a game. They are generated, committed and size-budgeted. `llms.txt` is the index and `llms-full.txt` is the core corpus (360 KB cap) if you genuinely need it: everything except the topic bundles' pages (rooms, splat characters), which it points to. Then: copy a template, edit `src/game.ts`, run `npm run dev`, follow one recipe. That is the whole path. ## Commands | Command | Does | | ----------------------- | ----------------------------------------------------------------------------------- | | `npm install` | The only build step. Packages resolve to `src/` via the `gameable-source` condition | | `npm run dev` | Points you at an example; there is no root dev server | | `npm run build` | `tsdown` build of every package | | `npm run typecheck` | `tsc -b --noEmit`, then `tools/typecheck-apps.mjs` for templates and examples | | `npm run lint` | ESLint flat config, type-aware | | `npm run format` | Prettier write; `npm run format:check` to verify | | `npm test` | vitest across `packages/**`, `templates/**` and `tests/**` | | `npm run test:boundary` | The real component in node: jco componentize + transpile, smoke and parity | | `npm run test:e2e` | Playwright: splat-viewer goldens and the FPS template, direct mode | | `npm run test:e2e:wasm` | The same e2e suite against the compiled wasm guest | | `npm run test:llm-eval` | Score a small model on ~20 prompts against the docs bundles | | `npm run docs:api` | TypeDoc markdown into `docs/api/` (generated, gitignored) | | `npm run docs:dev` | VitePress dev server, port 5173. Needs `docs:api` first | | `npm run docs:build` | VitePress production build. Needs `docs:api` first | | `npm run docs:games` | Build the playable examples into `docs/public/play/` for the docs site | | `npm run docs:llms` | Regenerate `llms.txt`, `llms-full.txt` and the task bundles | | `npm run docs:lint` | Dead links, templates, recipe file paths, snippets, llms freshness, size budgets | | `npm run check` | lint + typecheck + test + docs:lint. **Run this before every commit.** | Scripts must work in Git Bash and in `cmd`. Never put PowerShell-only syntax in a package script. Build helpers are node `.mjs` files taking array arguments with forward-slashed paths, because Windows paths break shell-string pipelines. ## Hard rules 1. **Import `three/webgpu`, `three/tsl` or `three/addons/...` — never bare `three`.** The bare entry point pulls in the WebGL renderer and can create a second three singleton. `gameable/no-bare-three-import` fails the build. 2. **No allocation in `fixedUpdate`, `update`, or any guest system.** These run 60+ times a second. Preallocate typed arrays, memoise subarrays, pool command objects, use dirty flags. 3. **Assets are addressed by string id only.** Never a path, never a URL, in game code or across the wasm boundary. Add a manifest entry instead. 4. **When the task is "make a game", never edit `packages/*`.** Edit the game's own `src/`. If a game genuinely cannot be written without an engine change, say so and stop — that is a missing feature, not a workaround. 5. **Never edit generated files.** `llms*.txt`, `docs/api/`, `docs/wit/`, `packages/*/src/generated/` and `package-lock.json` are outputs. Change the generator and rerun it. 6. **Run `npm run check` before committing.** A red `check` is not a commit. 7. **Nothing calls `navigator.gpu.requestAdapter()`.** The renderer owns the `GPUDevice`; everything else borrows `renderer.backend.device`. Bootstrap order is `initWebGPUPatches()` -> `renderer.init()` -> ORT device -> lift pipelines. 8. **Private three internals live in exactly one file.** All `renderer.backend.*` access goes through `packages/splat/src/backendBuffers.ts`. Do not reach into the backend anywhere else. 9. **Seed randomness inside `init()` from `env.seed()`.** Wizer snapshots the QuickJS heap at build time, so module-level state is frozen into the binary and every run would be identical. 10. **The guest gets one export per frame.** `tick(frame-input) -> frame-output`. Continuous data is packed `list`; structural changes are commands. Physics queries are the only synchronous host imports. Do not add a per-entity call. 11. **Keep direct mode and wasm mode identical.** They share the guest runtime on purpose, and a parity test hashes both. If they diverge, that is a bug in the engine, not a configuration difference. 12. **Every exported symbol gets TSDoc with a runnable `@example`.** ESLint enforces it on `packages/*/src/index.ts`, and the docs bundles are built from it. 13. **Dependencies are pinned exactly** (`save-exact=true`), and `three` is additionally pinned in root `overrides`. Do not widen a range. The one exception is what the published packages ask of an app: their `three` peer dependency is `>=0.186.0 <0.187.0`, so an app on a later 0.186 patch installs (an exact peer pin failed `npm install` the day three shipped 0.186.1). The repository itself still develops and tests against the exact pin. 14. **Placeholder assets are CC0 only** and capped at 12 MB. Anything with attribution requirements does not go in `gameable/placeholder`. ## Repository map | Path | What | | --------------------------------------------------------- | --------------------------------------------------------- | | `packages/core` | Engine, loop, module registry, cameras, asset registry | | `packages/sdk` | The only import game code needs; owns the key table | | `packages/wasm-host` | Component loading, host bindings, direct mode | | `packages/splat` | Splat loading plus the `AnimatedGaussianSplat` fork | | `packages/character`, `animation` | Splat avatars (ORL and GNM rigs) and the layered animator | | `packages/assets-aosrig` | The `aosrig_v0` sample character GLB (MHR body, GNM head) | | `packages/physics-jolt`, `input`, `audio`, `assets*` | Host modules | | `packages/cli`, `create-gameable`, `vite-plugin-gameable` | Tooling | | `packages/test-harness` | Mock host, record/replay, determinism helpers | | `templates/fps`, `templates/third-person` | The two shipped scaffolds | | `templates/visit` | A character's page: the studio exporter's inputs in | | `examples/`, `fixtures/` | Viewers, a Rust guest, and smoke content | | `tests/boundary`, `tests/e2e` | The real component in node; Playwright in a browser | | `wit/` | The `gameable:engine` WIT world | | `docs/` | VitePress source: how to use the engine, nothing else | | `adr/` | Decision records: why the engine is the way it is | | `STYLE.md` | The Gameable look: tokens, type, components, rules | | `tools/docs/` | `gen-llms.mjs`, `lint.mjs`, `bundles.json` | | `tools/eslint/` | Local hard-rule plugin | | `tools/llm-eval/` | The small-model scoreboard (M6) | ## Conventions - TypeScript everywhere, ESM only, `type: "module"`. - Package READMEs use six fixed headings: What, When to use, Install, Minimal example, API, Gotchas. `docs:lint` enforces this. - Recipes use five fixed headings: Goal, Files you will edit, Steps, Verify, See also — and edit at most two files. `tools/docs/recipe-template.md` is the shape. - ADRs are immutable once accepted; supersede, do not rewrite. - Commit messages: `type: summary`, imperative, lowercase type. - **Anything with a look follows `STYLE.md`** — the Gameable tokens (dark canvas, mint accent, pink for errors, Plus Jakarta Sans, pills and 16px cards) copied from the `aos-gameable-cc` frontend. Docs theme, template shells, the HUD, overlays and error banners all use them. Do not invent a colour, radius or font. ## When you are stuck `docs/troubleshooting.md` is symptom-first. `docs/glossary.md` decodes the jargon. `adr/` explains why something is the way it is — read the ADR before proposing to change a decision. # FILE: docs/start/03-first-adventure.md # Build your first adventure Twenty minutes from nothing to a third-person game you can play, and one file to change. ## 1. Scaffold ```sh npm create gameable my-adventure -- --template third-person cd my-adventure npm run dev ``` Open the page, click the canvas, and play: WASD to move, shift to run, space to jump, the mouse to swing the camera, `E` to interact, `1` and `2` to answer a question. A skinned character walks around a gaussian-splat arena with the camera on a boom behind it; two people will talk to you, two chests will open, one of them has the key, and the door in the north wall opens when you bring it. That is the starting point, not the destination. The rest of this page explains what you were given so you can change it. Working inside the engine repository instead? The same template is a workspace member: ```sh npm install npm run dev -w templates/third-person ``` ## 2. What the template contains ``` my-adventure/ ├─ index.html ├─ vite.config.ts one plugin: gameable() ├─ src/ │ ├─ main.ts the host: engine boot, lights, arena collider, sandbox │ ├─ game.ts THE FILE YOU EDIT │ ├─ prefabs.ts what things are made of, and the tags that say what they are │ ├─ arena.ts where things spawn │ ├─ dialogue.json what people say │ ├─ hud.ts what the HUD shows │ ├─ assets.json asset ids to files │ └─ systems/ │ ├─ locomotion.ts idle -> walk -> run -> jump -> fall, and the orbit camera │ ├─ interact.ts the E prompt, chests, the key, the door │ └─ dialogue.ts walking a conversation, expressions, gaze ├─ tests/game.test.ts the whole game, driven headlessly └─ AGENTS.md these rules, scoped to your game ``` `game.ts` and everything it imports are compiled into the **wasm guest**. It may not touch the DOM, fetch anything, or ask for the time. `main.ts` and the host files it loads stay in the browser. That is the same split as [the FPS template](./02-first-fps.md); if you have read that page, skip to section 4. ## 3. The shape of `game.ts` ```ts import { defineGame } from 'gameable'; export default defineGame({ assets: ['env.arena', 'char.guide', 'sfx.key', 'sfx.door', 'sfx.talk', 'sfx.step'], world: { gravity: -9.81, maxEntities: 256 }, player: { prefab: HeroPrefab, spawn: HERO_SPAWN, camera: 'thirdPerson', distance: 4.5, height: 1.5, }, spawns: [ place(GuidePrefab, arenaSpawns.npcs[0], NPC_CENTRE), place(WandererPrefab, arenaSpawns.npcs[1], NPC_CENTRE), place(KeyChestPrefab, arenaSpawns.chests[0], CHEST_CENTRE), place(ChestPrefab, arenaSpawns.chests[1], CHEST_CENTRE), place(DoorPrefab, arenaSpawns.door, DOOR_CENTRE), ], rules: { walkSpeed: 1.6, runSpeed: 4, cameraDistance: 4.5, interactRange: 2, doorTravel: 2.4, winMessage: 'You escaped', }, systems: [locomotionSystem, interactSystem, dialogueSystem, updateHud], }); ``` The level is a `spawns` list rather than a loop in `init`, and that is worth a sentence. Every prefab says what it is with **tags** — `Interactable` plus one of `Chest`, `Door`, `Npc`, `Key` — so nothing has to be looked up and patched after the spawn. `classifyInteractables` turns those tags into one integer per entity once, during `init`, and the interact system reads that integer instead of asking the ECS four questions sixty times a second. ## 4. The camera is the interesting part In a first-person game the camera is the player's eyes. In a third-person game it is a machine, and the guest does not own it. `src/systems/locomotion.ts` states an intent: ```ts ctx.camera.follow(hero, { yaw, pitch, distance: 4.5, height: 1.5 }); ``` That writes one `camera-state` record: the orbit pivot, the direction the player is looking, and how long the boom should be. The **host** builds a spring arm from it, casts a ray from the pivot towards where the camera wants to sit, and pulls the boom in to just short of whatever it hits. You never write the collision, and the guest never learns the geometry of your level. Two consequences worth knowing: 1. **Forward is away from the camera.** That is why one system owns both the walk direction and the orbit — they are the same number. 2. **The declarative rig re-states the camera after your systems run**, out of the built-in look accumulator. `orbit()` clamps `ctx.camera.look.pitch` in place so the two cannot disagree. Copy that pattern if you take the pitch somewhere else. ## 5. A state machine, in outline ```ts locomotionState.state = grounded ? planar < STILL_SPEED ? IDLE : running ? RUN : WALK : wantsJump || vy > 0 ? JUMP : FALL; character.setState(hero, locomotionState.state, vx, vy, vz, grounded); ``` `grounded` comes from the vertical velocity the host wrote into this frame's body rows, plus a short lock after a jump is requested — a jump is a request, not a teleport, and the upward speed only arrives on the next frame. `character.setState` is the line that matters. It is the contract the character's animator blends from: the planar speed picks idle, walk or run, and the name is your own vocabulary. The hero prefab's `character: 'char.hero'` is what puts the sample rig on the capsule's body; the NPCs' `character: 'char.guide'` does the same, and the `setExpression` and `lookAt` the dialogue system sends per line are recorded against them. **Nothing in `src/` changes when a splat character replaces the mesh.** ## 6. Talking to people `src/dialogue.json` is data: ```json { "guide": { "speaker": "Guide", "lines": [{ "text": "You are awake. Good.", "face": "smile" }], "question": { "text": "Shall I tell you which chest?", "face": "smile", "yes": { "text": "The one in the middle of the floor.", "face": "smile" }, "no": { "text": "Suit yourself.", "face": "neutral" } } } } ``` A JSON import is bundled into the wasm guest along with everything else, so this file ships in the component; there is no fetch and no loading state. Which script an NPC runs is a tag on its prefab, so adding a character with something new to say is an entry in this file and a prefab, and no change to the system that walks it. While anyone is talking, `dialogueState.active` is true and the locomotion system stops driving the hero. You cannot walk away mid-sentence. ## 7. Change something Pick one; each is a recipe of its own: - [Add an interactable](../recipes/add-an-interactable.md) — a lever, in two files. - [Add NPC dialogue](../recipes/add-npc-dialogue.md) — a third person with their own script. - [Tune the follow camera](../recipes/tune-the-follow-camera.md) — boom, height, pitch range, shoulder. - [Add a locomotion state](../recipes/add-a-locomotion-state.md) — a crouch, reported to the animator. ## 8. Test it ```sh npm test ``` `tests/game.test.ts` runs the real systems against a mock host — no browser, no renderer, no physics engine. It walks the hero at both speeds, jumps it, checks that the camera rides a boom behind it, opens a chest, refuses the door without the key and opens it with one, takes both branches of the guide's question, asserts that the character commands go out, and checks that the same seed produces the same frames twice. That is the loop to work in. The browser is for looking at it. ## 9. Ship it ```sh npm run build # guest component + production bundle npm run preview ``` `npm run dev` runs your TypeScript directly; `npm run build` compiles the very same TypeScript — `src/dialogue.json` included — into a QuickJS WebAssembly component. The host code is identical and `import.meta.env.GAMEABLE_MODE` is the only difference. If the two behave differently, that is an engine bug — report it rather than working around it. ## See also - [Build your first FPS](./02-first-fps.md) — the same engine, the other genre - [ECS and game code](../concepts/ecs.md) - [The wasm boundary](../concepts/wasm-boundary.md) - [Characters](../concepts/characters.md) - [Recipes](../recipes/index.md) # FILE: docs/concepts/ecs.md # ECS and game code bitecs 0.4 is the ECS and it lives **inside the guest**. The host owns rendering, physics, input, audio, assets and characters; the guest owns entities, components and systems. `gameable` is the only import game code needs. ## A game is one declaration ```ts import { defineGame, prefab, Enemy, Health } from 'gameable'; const Player = prefab({ body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'character', mass: 80 }, health: 100, }); const Grunt = prefab({ asset: 'enemy-capsule', body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'dynamic', mass: 60 }, health: 30, components: [Enemy], }); export default defineGame({ assets: ['arena', 'enemy-capsule', 'shot'], world: { gravity: -9.81, maxEntities: 512 }, player: { prefab: Player, spawn: [0, 1, 0], camera: 'firstPerson', eyeHeight: 1.7 }, spawns: [{ prefab: Grunt, position: [0, 1, -6] }], rules: { walkSpeed: 4, damage: 10 }, systems: [movePlayer, shoot, updateHud], }); ``` `assets`, `world`, `player` and `spawns` are sugar over built-in systems, so two games that declare the same thing behave identically. `rules` is handed straight back as `ctx.rules`. Anything the declarative layer cannot express is a plain system. ## Tick order Fixed, because determinism depends on it: 1. decode `frame-input` into preallocated storage, and apply `player-joined` / `player-left` (a room's authority spawns or despawns `player` for that player) 2. ingest rigid-body transforms (stride 15) into `Transform` and `Velocity`, to read — the host drew them itself when it stepped, so they are not packed back out 3. built-in look accumulator (`input.mouse.dx/dy` → `camera.look`) 4. built-in velocity integration, for entities with `Velocity` and no `RigidBody` — this is where `world.gravity` applies 5. your `systems[]` that run where this guest runs, in declaration order 6. your `update()` 7. built-in camera rig, from `player.camera` 8. pack `frame-output.transforms` and the command list Each user system is wrapped: a throw is logged and the frame still returns a valid output. Eight consecutive failing ticks mark the guest `dead` and the host rebuilds the sandbox. ## Systems A system is a plain function of the frame context: ```ts import type { GameContext } from 'gameable'; // Reused across frames: a system must not allocate. const eye = { x: 0, y: 0, z: 0 }; function shoot(ctx: GameContext): void { if (!ctx.input.mousePressed(1)) return; eye.x = Transform.x[ctx.player]; eye.y = Transform.y[ctx.player] + 1.7; eye.z = Transform.z[ctx.player]; const hit = ctx.physics.raycast(eye, forward, 100, undefined, ctx.player); if (hit) Health.current[hit.entity] -= 10; } ``` `ctx` carries `world`, `frame`, `dt`, `elapsed`, `rng`, `contacts`, `events`, `player`, `rules`, `config`, the `spawn` / `despawn` / `assetId` helpers, and the facades. It is the same object every frame, mutated in place — read it, do not retain it. ## Built-in components Structure-of-arrays: `Transform.x[entity]`, never `Transform[entity].x`. Every array is preallocated to `world.maxEntities` (default 4096), so writing one never allocates. | Component | Lanes | | ------------ | ---------------------------------- | | `Transform` | `x y z`, `qx qy qz qw`, `sx sy sz` | | `Renderable` | `asset`, `flags`, `dirty` | | `RigidBody` | `handle`, `kind`, `shape`, `dirty` | | `Character` | `bundle`, `dirty` | | `Velocity` | `x y z`, `ax ay az` | | `Health` | `current`, `max` | plus the tags `Player`, `Enemy` and `Pickup`. `createWorld`, `addEntity`, `removeEntity`, `addComponent`, `removeComponent`, `hasComponent`, `query`, `Not`, `Or` and `And` are re-exported from bitecs unchanged, so your own components work exactly as bitecs documents. ### Writing `Transform` by hand `spawn` and the built-in velocity integration tell the packer which entities moved. Nothing watches the arrays themselves, so a system that writes a lane directly has to say so — otherwise the row is never packed and the host never moves the object: ```ts import { Transform, TRANSFORM_FLAGS, markMoved } from 'gameable'; function bob(ctx: GameContext): void { Transform.y[ctx.player] = 1 + Math.sin(ctx.elapsed); markMoved(ctx.player, TRANSFORM_FLAGS.POSITION); } ``` `markMoved(entity)` with no flags marks position, rotation and scale. Flags accumulate until the end of the tick, so marking twice costs nothing. Body ingestion is the deliberate exception: it fills `Transform` and `Velocity` for a physics-driven entity but marks nothing, because the host already moved that object from the body's own row. Read those lanes freely; to _move_ the entity, send `physics.teleport` or `physics.setVelocity` rather than writing the lane, or the body will simply put it back next step. ## Prefabs mint the ids Every handle is minted by the **guest**, so the host never has to hand one back. `prefab()` is pure and safe at module scope; `spawn()` allocates the entity id from bitecs, the body id from a counter that starts at 1, writes the built-in components, and queues the `spawn` / `add-body` / `spawn-character` commands the host needs. ```ts const crate = ctx.spawn(Crate, { x: 0, y: 2, z: -5 }); ctx.despawn(crate); // emits remove-body then despawn ``` ## Zero allocation, and why it matters Systems run 60+ times a second inside a QuickJS heap with a simple collector. A steady-state tick in Gameable Engine allocates nothing: - `frame-output.transforms` is a preallocated `Float32Array`, returned as a memoised subarray — the same row count returns the same object every frame. - Commands are pooled per tag and mutated in place; `commandPoolSize()` stops growing once a game reaches its steady state, and a test asserts it. - `input.axis2(...)` and `input.mouse` return pooled objects. - `hud.set(model)` shallow-compares against the previous model and only calls `JSON.stringify` on a real change. Follow the same rule in your own systems: hoist your vectors, hoist your query term arrays, and mutate. ## Snapshot and restore `snapshot()` serialises the built-in component arrays, the entity id space, the SDK counters and the RNG state into a versioned little-endian byte string. Custom components are not covered automatically — return them from `defineGame({ snapshot })` and read them back in `defineGame({ restore })`, and they are JSON-encoded into the snapshot header. Restore rebuilds the entity id space exactly, so a run resumed from a snapshot produces the same frames as the run it was taken from. The determinism test relies on it. ## See also - [The wasm boundary](./wasm-boundary.md) - [Write a game system](../recipes/write-a-game-system.md) - `packages/sdk/README.md` — gameable - [Build your first FPS](../start/02-first-fps.md) # FILE: packages/sdk/README.md # gameable ## What The guest-side API. `defineGame`, the bitecs re-exports, the built-in SoA components and systems, prefabs, and the `input` / `physics` / `camera` / `hud` / `audio` / `character` facades that marshal across the wasm boundary without allocating. Everything here runs in two places and must behave identically in both: QuickJS inside the compiled component, and V8 in `mode: 'direct'` (the Vite dev server and vitest). ## When to use Always, when you are writing a game. This is the single import a game module needs. You do not import `gameable/core`, `three`, or anything else. ## Install ```sh npm install gameable ``` Inside this repository the package is a workspace member and needs no install. ## Minimal example ```ts import { defineGame, prefab, Health } from 'gameable'; const Player = prefab({ name: 'player', body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'character', mass: 80 }, health: 100, }); const Enemy = prefab({ asset: 'enemy-capsule', body: { shape: 'capsule', dims: [0.3, 0.9], kind: 'dynamic', mass: 60 }, health: 30, }); export default defineGame({ assets: ['arena', 'enemy-capsule', 'shot'], world: { gravity: -9.81 }, player: { prefab: Player, spawn: [0, 1, 0], camera: 'firstPerson' }, spawns: [{ prefab: Enemy, position: [0, 1, -6] }], systems: [ // Walk with WASD, in the camera's yaw frame. (ctx) => { const move = ctx.input.axis2('A', 'D', 'S', 'W'); const yaw = ctx.camera.look.yaw; const vx = (move.x * Math.cos(yaw) - move.y * Math.sin(yaw)) * 4; const vz = (-move.x * Math.sin(yaw) - move.y * Math.cos(yaw)) * 4; ctx.physics.moveCharacter(ctx.player, vx, 0, vz, ctx.input.pressed('Space')); }, // Hitscan on left mouse button. (ctx) => { if (!ctx.input.mousePressed(1)) return; const hit = ctx.physics.raycast(eye, forward, 100, undefined, ctx.player); ctx.audio.play('shot', { entity: ctx.player }); if (hit) Health.current[hit.entity] -= 10; }, // HUD JSON only crosses on the frames it changed. (ctx) => { ctx.hud.set({ health: Health.current[ctx.player] }); }, ], }); const eye = { x: 0, y: 1.7, z: 0 }; const forward = { x: 0, y: 0, z: -1 }; ``` ## API - **`defineGame({ assets, world, player, spawns, rules, systems, init, update, shutdown, snapshot, restore })`** — the declaration a game module default-exports. Declarative fields are sugar over built-in systems that run before yours, in a fixed order. - **`featuresOf(definition)`** — the game's `features` block as a frozen table, name to options (`true` becomes `{}`, `false` is dropped). The host reads it to decide which feature modules to load. - **`roomSeats(definition)`** — the seats a room game declares, `features.multiplayer.maxPlayers` (`DEFAULT_ROOM_SEATS`, 8, for `multiplayer: true`; undefined without the feature). Seats are ids `0..seats - 1`. The guest makes exactly that many player slots and ignores a join past them; `createEngineRoomGame` and `createRoom` read the same number. `roomSendHz` reads `sendHz` (20; 1 to 60). - **`player.spawn`** — one point `[x, y, z]` for every seat, a list of points (seat `i` takes `list[i % n]`), or `(seat, ctx) => ({ x, y, z })`. A single-player game spawns seat 0. The function runs inside the step: keep it deterministic (seat, `ctx.rules`, `ctx.rng`). - **`ctx.players`** — a read-only `Map` from id to `PlayerHandle`, plus `host` (the first joiner, who stays host until they leave or drop; then the lowest seat still joined; undefined with nobody joined; kept by snapshots; also `PlayerHandle.isHost`) and `list`, the same players in id order in one array kept for the whole run. Walk `list` with an index in a system: iterating the map allocates. - **`ctx.data`** — player documents in the room's store, written by the authority. `ctx.players.get(id).data` is the document the room loaded at join (`null` for none), `.savedAt` when the store last wrote it and `.joinedAt` the server's clock at the load (ms since the epoch; offline time is `joinedAt - savedAt`; `savedAt` is `null` with no document, `joinedAt` without a store). `ctx.data.save(id, doc)` replaces it and has the room write it, at most once per 6 s per player and always when the player leaves or the room closes. `ctx.data.exchange(a, b, give, take)` trades all or nothing (`applyTransfer`: numbers move amounts, lists move items; an object item such as a pet matches by value, whatever its key order) and returns an id; its result is in `ctx.data.results()` a tick or more later, and on success both players' `data` already show the trade. `ctx.data.game` and `ctx.data.saveGame(doc)` are the game's own document. On a client every call does nothing (logged once). - **`defineMessage(name, check, { maxBytes })`** — a checked message for `ctx.net.messages(def)` and `ctx.net.send(def, payload)`; a bad payload is dropped and counted in `ctx.net.stats`. - **`prefab(spec)` / `spawn(def, position, rotation?)` / `despawn(entity)`** — entity templates. `spawn` mints the entity and body ids and queues the `spawn` / `add-body` / `spawn-character` commands. Freed body ids are reused, lowest first: ids stay under the live body count (`maxBodies`). - **ECS** — `createWorld`, `addEntity`, `removeEntity`, `addComponent`, `removeComponent`, `hasComponent`, `query`, `Not`/`Or`/`And` re-exported from bitecs 0.4, plus the built-in SoA components `Transform`, `Renderable`, `RigidBody`, `Character`, `Velocity`, `Health` and the tags `Player`, `Enemy`, `Pickup`. `configureEcs(n)` resizes them; the default is 4096. - **Facades** — `input` (`isDown`, `pressed`, `released`, `axis2`, `mouse`, `gamepad`), `physics` (`raycast`, `raycastBatch`, `overlapSphere`, `moveCharacter`, `applyImpulse`, `setVelocity`, `teleport`), `camera` (`firstPerson`, `follow`, `set`, `lookAt`, `look`), `hud` (`set`, `invalidate`, `clear`), `audio` (`play`, `stop`, `listener`), `character` (`setState`, `setClipWeights`, `setExpression`, `lookAt`, `say`). - **`createGuest(host, definition)`** — the runtime behind both sandbox modes. `gameable/host` calls it for you. - **`createRng(seed)`**, `assetId(name)`, `writeSnapshot` / `readSnapshot`, `TransformPacker`, `BodyIndex`, `CommandBuffer`, `commandPoolSize()`. - **`gameable/sdk/keycodes`** — the canonical key table. `gameable/input` imports it so host and guest agree bit for bit. - **`gameable/sdk/prelude`** — the QuickJS compatibility layer, imported first by the componentize entry. `createThirdPersonController(clips?)` shares camera-relative acceleration, shortest-path turning, a configurable idle-turn threshold (default 90 degrees), and contact-driven jump phases. Create it once, call `reset(ctx)` from game init and `update(ctx, frozen?)` from a system. On a room's authority call `updatePlayers(ctx, frozen?)` instead: it moves each player's own entity (the one they possess) from their own input, with their own camera, keeping one state per seat (`stateOf(id)`), reset when the seat's entity changes. Tune movement and camera through `ctx.rules`. Optional `{ idle, walk, run, rise, fall, land }` clip names enable an explicit base blend; the asset bundle must contain all six clips. Otherwise the animator uses its standard speed blend. `physics.isGrounded(entity)` reads the last packed Jolt contact result without a host call. It returns false for unknown, steep, unsupported and airborne states, including a stationary jump apex. The body buffer now has stride 15; rebuild hosts, guests and replay fixtures together. Snapshots use version 2 because the contact lane is persisted; version 1 snapshots are rejected explicitly. ## Gotchas - **One direct guest per JS realm.** The component arrays are shared, so a direct guest started before another throws on its next `tick`. Run one as wasm. A `client` role runs no level `spawns`. - **Seed randomness in `init`, from `env.seed()`.** Wizer snapshots the QuickJS heap at build time, so anything derived from `Math.random()` or `Date.now()` at module scope is frozen into the binary. In the component, `Math.random()` throws until the runtime seeds it; in V8 the prelude leaves the global alone, so a game that calls it there will still be non-deterministic. Use `ctx.rng`. - **Never `instanceof` or `.subarray()` an incoming list.** jco hands the guest a plain `Array` for `list` and `list`; direct mode hands it a `Float32Array`. The types say `ArrayLike` for exactly this reason. - **Never emit a zero-length `list`.** jco's lifter rejects the pointer QuickJS returns for one. `TransformPacker` emits a single all-zero row instead, which the host skips. - **Assets are string ids.** Resolve them in `init`; the SDK caches, and warns when a name is resolved during `tick`. - **No allocation in a system.** `axis2`, `input.mouse` and the command list are pooled objects that are reused every frame. Read them; never retain them. `for (const [id, p] of ctx.players)` makes an iterator and a pair per player: walk `ctx.players.list` by index instead. - **`ctx.data.save` serialises the document.** Call it when the document changed, not every tick. Saves made between an exchange and its result are dropped by the room (the SDK saves both traded documents when the result arrives); a player who leaves inside that window loses those saves, not the trade. - **Key names are case-sensitive**, except that a bare letter or F-key also answers in lower case (`'w'`, `'f1'`). `'shift'` is unknown: write `'Shift'`. - **A bad `features.multiplayer.maxPlayers` fails `init`**, in a single-player run too: anything but a whole number from 1 to 4097 is `init-failed`. - **A dropped host hands the role on while the room holds their seat** (30 seconds on the room server, its catalog entry's `reconnectSeconds`). The room leaves a held seat out of `frame-input.players`, and the SDK reads that as gone for the host role alone: `host` moves to the lowest seat in the list and stays there when the old host is back. The player is still joined (`connected`), and the guest hears `player-left` only when the hold times out. - **Floats cross as `f32`.** The SDK rounds on the way out so direct mode and wasm mode stay bit-identical; do not be surprised when `0.1` comes back as `0.10000000149011612`. # FILE: templates/third-person/src/game.ts /** * The game. * * **This is the file to edit.** Everything else in `src/` is scenery: the * prefabs say what things are made of, the systems say what they do, and this * file says which of them exist, where, and with what numbers. * * The declarative fields (`assets`, `world`, `player`, `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`, which is where * every tuning number in this template comes from — change one there and * nothing else has to know. * * The whole file runs **inside the wasm guest**. It may not touch the DOM, * fetch anything, or ask for the time; the only way out is a command in * `frame-output`, and the facades on `ctx` build those for you. */ import { defineGame, query, type GameContext, type PrefabDef, type SpawnSpec } from 'gameable'; import { arenaSpawns, type SpawnPoint } from './arena'; import { resetHud, updateHud } from './hud'; import { CHEST_CENTRE, CHEST_COLOUR, Chest, ChestPrefab, DOOR_CENTRE, DOOR_COLOUR, Door, DoorPrefab, GuidePrefab, HERO_CENTRE, HERO_COLOUR, HeroPrefab, KeyChestPrefab, MAX_ENTITIES, NPC_CENTRE, NPC_COLOUR, Npc, WandererPrefab, resetInteractables, tint, } from './prefabs'; import { PHYSICS_OPTIONS } from './physicsOptions'; import { dialogueSystem, resetDialogue } from './systems/dialogue'; import { classifyInteractables, interactSystem, resetInteract } from './systems/interact'; import { locomotionSystem, resetLocomotion } from './systems/locomotion'; import { paintPlayers, resetPaint } from './systems/paintPlayers'; // A room server loads this module for the definition; it finds the page's // physics options here too, so the two never keep separate copies. export { PHYSICS_OPTIONS } from './physicsOptions'; /** Where the hero starts, lifted so the capsule's feet are on the floor. */ const HERO_SPAWN: readonly number[] = [ arenaSpawns.hero.position[0], arenaSpawns.hero.position[1] + HERO_CENTRE, arenaSpawns.hero.position[2], ]; /** * One entry of the declarative `spawns` list: on its own feet, facing its * spawn yaw. A capsule had no front; a person does. * * @param prefab The prefab to place. * @param spawn A point from `src/arena.ts`. * @param centre Height of the body's centre above its feet, metres. * @returns The spawn entry. */ function place(prefab: PrefabDef, spawn: SpawnPoint, centre: number): SpawnSpec { const half = spawn.yaw * 0.5; return { prefab, position: [spawn.position[0], spawn.position[1] + centre, spawn.position[2]], rotation: [0, Math.sin(half), 0, Math.cos(half)], }; } /** * Paint the level, once, the frame it exists. * * The people wear the sample character; the props are boxes the size of their * own physics bodies, so a coat of paint is the difference between "an * adventure" and "four grey lozenges". * * @param ctx The frame context. * @returns Nothing. */ function paintTheLevel(ctx: GameContext): void { // Alone there is one hero; in a room `paintPlayers` paints each as they join. if (ctx.player !== 0) tint(ctx.player, HERO_COLOUR); for (const e of query(ctx.world, [Npc])) tint(e, NPC_COLOUR); for (const e of query(ctx.world, [Chest])) tint(e, CHEST_COLOUR); for (const e of query(ctx.world, [Door])) tint(e, DOOR_COLOUR); } export default defineGame({ // Optional engine features this game opts into; the page resolves each by name. // Add `multiplayer: { maxPlayers: 6 }` to play in rooms with friends: every // player explores, opens chests and talks for themselves // (docs/recipes/play-with-friends.md). features: { characters: true }, // Manifest ids, resolved once during init. Never a path, never a URL. assets: ['env.arena', 'char.hero', 'char.guide', 'sfx.key', 'sfx.door', 'sfx.talk', 'sfx.step'], // The same gravity the page's physics world gets (see `src/physicsOptions.ts`). world: { gravity: PHYSICS_OPTIONS.gravity[1], maxEntities: MAX_ENTITIES }, player: { prefab: HeroPrefab, spawn: HERO_SPAWN, camera: 'thirdPerson', // The boom, restated by `src/systems/locomotion.ts` every frame so the // orbit it computes and the rig the engine drives cannot drift apart. distance: 4.5, height: 0.4, sensitivity: 0.0024, }, // The level. Five props, all of them declarative, because every prefab says // what it is with tags — nothing has to be patched up after the spawn. 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), ], // Every number the systems read. This is the tuning surface. rules: { // Near the speeds the rig's clips were baked at, so the feet do not skate. walkSpeed: 1.6, runSpeed: 4, jumpLockFrames: 10, cameraDistance: 4.5, cameraHeight: 0.4, cameraPitch: -0.22, cameraMinPitch: -1.15, cameraMaxPitch: 0.6, interactRange: 2, // Cosine of the half-angle of the "in front of me" cone: 0.3 is about 72 // degrees either side, which is generous enough not to feel fiddly. interactFacing: 0.3, messageSeconds: 2.5, doorTravel: 2.4, doorSpeed: 1.6, winMessage: 'You escaped', }, init: (ctx) => { resetInteractables(); resetInteract(); resetDialogue(); resetLocomotion(ctx); resetHud(); resetPaint(); classifyInteractables(ctx); paintTheLevel(ctx); ctx.hud.invalidate(); console.log(`adventure ready: ${String(arenaSpawns.npcs.length)} people to talk to`); }, // Run in order, once per fixed step, after the built-ins. Locomotion decides // where the hero and the camera are, interact decides what `E` would do, and // dialogue runs whatever conversation interact started. With multiplayer on // they run on the room's authority, once per player, each from that player's // keys; a shared chest or door stays shared, a conversation is one player's. systems: [paintPlayers, locomotionSystem, interactSystem, dialogueSystem, updateHud], }); # FILE: templates/third-person/src/prefabs.ts /** * What the things in this game are made of. * * A prefab is a pure declaration — it resolves nothing and is safe at module * scope. `ctx.spawn(Prefab, position)` turns one into an entity, writes the * built-in components, and queues the commands the host needs. * * A prop is a box the size of its own physics shape, so changing `dims` * changes what you see as well as what you hit. A prefab with a `character` * draws a real rig instead, standing on the same body's feet. * * **The tags are the vocabulary.** `Interactable` says "E does something here", * and one of `Chest` / `Door` / `Npc` / `Key` says what. That is why every * prefab below is declarative — nothing has to be patched up after the spawn, * so `src/game.ts` can lay the level out in a `spawns` list and the systems can * ask what a thing is with one array read. */ import { activeRuntime, Pickup, Player, prefab } from 'gameable'; /** * Entity ceiling. Must match `world.maxEntities` in `src/game.ts`, because the * per-entity lanes below are sized from it. */ export const MAX_ENTITIES = 256; /** Hero capsule radius, metres. */ export const HERO_RADIUS = 0.35; /** Half-height of the hero capsule's cylindrical section, metres. */ export const HERO_HALF_HEIGHT = 0.8; /** * Height of the hero capsule's centre above its feet. * * A Jolt capsule's origin is its centre, so a spawn point on the floor has to * be lifted by this much or the hero starts buried. */ export const HERO_CENTRE = HERO_RADIUS + HERO_HALF_HEIGHT; /** NPC capsule radius, metres. */ export const NPC_RADIUS = 0.35; /** Half-height of an NPC capsule's cylindrical section, metres. */ export const NPC_HALF_HEIGHT = 0.7; /** Height of an NPC capsule's centre above its feet. */ export const NPC_CENTRE = NPC_RADIUS + NPC_HALF_HEIGHT; /** Height of an NPC's eyes above its feet: what a hero looks at. */ export const NPC_EYE_HEIGHT = 1.55; /** Chest half extents, metres. */ export const CHEST_HALF: readonly [number, number, number] = [0.42, 0.28, 0.3]; /** Height of a chest's centre above its feet. */ export const CHEST_CENTRE = CHEST_HALF[1]; /** Door half extents, metres: a slab as wide as a doorway. */ export const DOOR_HALF: readonly [number, number, number] = [1.1, 1.2, 0.18]; /** Height of the door's centre above the floor. */ export const DOOR_CENTRE = DOOR_HALF[1]; /** Key half extent, metres. */ export const KEY_HALF = 0.12; /** How high above the floor a dropped key floats. */ export const KEY_HEIGHT = 0.5; // --------------------------------------------------------------------------- // Tags // --------------------------------------------------------------------------- /** Tag: `E` does something when the hero is in front of this. */ export const Interactable: Record = {}; /** Tag: a chest. Open it. */ export const Chest: Record = {}; /** Tag: this chest is the one with the key in it. */ export const HoldsKey: Record = {}; /** Tag: the door out. It only opens with the key. */ export const Door: Record = {}; /** Tag: somebody to talk to. */ export const Npc: Record = {}; /** Tag: this NPC runs the `guide` conversation in `src/dialogue.json`. */ export const Guide: Record = {}; /** Tag: this NPC runs the `wanderer` conversation in `src/dialogue.json`. */ export const Wanderer: Record = {}; /** Tag: a key lying on the floor. */ export const Key: Record = {}; // --------------------------------------------------------------------------- // Interaction kinds // --------------------------------------------------------------------------- /** Not interactable, or not yet classified. */ export const KIND_NONE = 0; /** A chest. */ export const KIND_CHEST = 1; /** The door. */ export const KIND_DOOR = 2; /** An NPC. */ export const KIND_NPC = 3; /** A key on the floor. */ export const KIND_KEY = 4; /** * Which kind each interactable is, and whether it has already been used. * * Flat lanes indexed by entity id: that is what a component is in this ECS, and * it means the interact system reads one number per candidate rather than * asking bitecs three questions. `classifyInteractables` fills `kind` in from * the tags once, in `init`; `used` is written as the game is played. */ export const interactables = { /** One of the `KIND_*` constants. */ kind: new Uint8Array(MAX_ENTITIES), /** 1 once a chest has been opened or a door has been unlocked. */ used: new Uint8Array(MAX_ENTITIES), }; /** * Forget every classification. Call from `defineGame({ init })` **before** * classifying, because module-level state survives a rebuild and is frozen * into the wasm component by Wizer. * * @returns Nothing. */ export function resetInteractables(): void { interactables.kind.fill(0); interactables.used.fill(0); } // --------------------------------------------------------------------------- // Prefabs // --------------------------------------------------------------------------- /** * The hero: a capsule driven by the host character controller, wearing the * sample character. * * Unlike the FPS player it is drawn, because the camera is behind it — that is * the whole point of a third-person game. */ export const HeroPrefab = prefab({ name: 'hero', character: 'char.hero', body: { shape: 'capsule', dims: [HERO_RADIUS, HERO_HALF_HEIGHT], kind: 'character', mass: 75, layer: { player: true }, mask: { defaultLayer: true, staticGeometry: true, character: true, pickup: true, trigger: true, }, flags: { reportContacts: true, lockRotation: true, noSleep: true }, }, health: 100, components: [Player], }); /** Body every NPC shares: a capsule that stands where it is put. */ const NPC_BODY = { shape: 'capsule', dims: [NPC_RADIUS, NPC_HALF_HEIGHT], kind: 'character', mass: 70, layer: { character: true }, mask: { defaultLayer: true, staticGeometry: true, player: true, character: true }, flags: { reportContacts: true, noSleep: true }, } as const; /** * The guide: the NPC standing in front of where the hero starts. * * `character: 'char.guide'` is the interesting line. It makes `ctx.spawn` emit * a `spawn-character` naming an id in `src/assets.json`, and the host loads * that rig, hides the capsule and blends its clips from whatever * `character.setState` last said. Point the id at a different entry and this * file does not change. */ export const GuidePrefab = prefab({ name: 'guide', body: NPC_BODY, character: 'char.guide', components: [Interactable, Npc, Guide], }); /** The wanderer: a second NPC, with a shorter script and no branch. */ export const WandererPrefab = prefab({ name: 'wanderer', body: NPC_BODY, character: 'char.guide', components: [Interactable, Npc, Wanderer], }); /** Body both chests share: a static box you cannot walk through. */ const CHEST_BODY = { shape: 'box', dims: CHEST_HALF, kind: 'fixed', layer: { defaultLayer: true }, mask: { player: true, character: true }, } as const; /** The chest with the key in it. */ export const KeyChestPrefab = prefab({ name: 'chest-key', body: CHEST_BODY, components: [Interactable, Chest, HoldsKey], }); /** A chest with nothing in it. Adventure games are like that. */ export const ChestPrefab = prefab({ name: 'chest', body: CHEST_BODY, components: [Interactable, Chest], }); /** * The door out: a kinematic slab. * * Kinematic, not fixed, because it moves and nothing pushes it — the interact * system slides it sideways with `physics.teleport`, which the host turns into * a `set-body-transform`. */ export const DoorPrefab = prefab({ name: 'door', body: { shape: 'box', dims: DOOR_HALF, kind: 'kinematic', layer: { defaultLayer: true }, mask: { player: true, character: true }, }, components: [Interactable, Door], }); /** The key, once a chest has coughed it up: a small gold box on the floor. */ export const KeyPrefab = prefab({ name: 'key', body: { shape: 'box', dims: [KEY_HALF, KEY_HALF, KEY_HALF], kind: 'fixed', layer: { pickup: true }, mask: { player: true }, flags: { sensor: true }, }, components: [Interactable, Key, Pickup], }); // --------------------------------------------------------------------------- // Paint // --------------------------------------------------------------------------- /** * Paint an entity: its placeholder mesh, or its character's materials. * * `set-material-param` is a normal frame-output command; there is no facade * for it on `ctx` because a real game sets a material through its asset, not * through a uniform. Call it right after `ctx.spawn`, once, never per frame. * * @param entity The entity to paint. * @param colour Linear RGB, each component 0..1. * @returns The entity, so this can wrap a `ctx.spawn` call. */ export function tint(entity: number, colour: readonly [number, number, number]): number { activeRuntime().commands.setMaterialParam(entity, 'color', { tag: 'color', val: { r: colour[0], g: colour[1], b: colour[2], a: 1 }, }); return entity; } /** Hero teal. */ export const HERO_COLOUR: readonly [number, number, number] = [0.24, 0.62, 0.72]; /** NPC amber. */ export const NPC_COLOUR: readonly [number, number, number] = [0.86, 0.66, 0.24]; /** Chest brown. */ export const CHEST_COLOUR: readonly [number, number, number] = [0.45, 0.3, 0.16]; /** Door slate. */ export const DOOR_COLOUR: readonly [number, number, number] = [0.3, 0.34, 0.42]; /** Key gold. */ export const KEY_COLOUR: readonly [number, number, number] = [0.92, 0.78, 0.2]; # FILE: docs/recipes/add-an-interactable.md # Add an interactable ## Goal A new kind of thing in the world that the hero can walk up to and press `E` at: a lever that unlocks the door without a key. When it is in reach the HUD says `E: pull lever`, and pulling it opens the door. ## Files you will edit - `src/prefabs.ts` - `src/systems/interact.ts` ## Steps 1. **Declare what it is.** Tags are the vocabulary: `Interactable` means "`E` does something here" and a second tag says what. Add both the tag and the prefab to `src/prefabs.ts`: ```ts /** Tag: a lever. Pull it. */ export const Lever: Record = {}; /** A lever: a thin post you pull once. */ export const LeverPrefab = prefab({ name: 'lever', body: { shape: 'box', dims: [0.12, 0.6, 0.12], kind: 'fixed', layer: { defaultLayer: true }, mask: { player: true, character: true }, }, components: [Interactable, Lever], }); /** Lever brass. */ export const LEVER_COLOUR: readonly [number, number, number] = [0.7, 0.55, 0.2]; ``` 2. **Give the kind a number.** The interact system reads one integer per candidate rather than asking bitecs four questions a frame, so every kind has a constant. Add it next to the others in `src/prefabs.ts`: ```ts /** A lever. */ export const KIND_LEVER = 5; ``` 3. **Classify it.** In `src/systems/interact.ts`, add one branch to `classifyInteractables`, which runs once during `init`: ```ts interactables.kind[e] = hasComponent(ctx.world, e, Lever) ? KIND_LEVER : hasComponent(ctx.world, e, Chest) ? KIND_CHEST : /* …the existing chain… */ KIND_NONE; ``` 4. **Give it a prompt.** Still in `src/systems/interact.ts`, one line in `promptFor`: ```ts if (kind === KIND_LEVER) return used ? '' : 'E: pull lever'; ``` 5. **Say what `E` does.** One branch in `activate`, which already has the door entity to hand: ```ts if (kind === KIND_LEVER) { interactables.used[entity] = 1; ctx.audio.play('sfx.door', { entity, volume: 0.8 }); // The door's own `used` lane is what `slideDoor` watches. if (interactState.door !== 0) interactables.used[interactState.door] = 1; notify(ctx, 'Somewhere, something heavy moves.'); return; } ``` 6. **Put one in the level.** Add a point to `src/arena.ts` and an entry to the `spawns` list in `src/game.ts` — the same two lines every other prop uses. Paint it in `init` with `tint(entity, LEVER_COLOUR)`. ## Verify ```sh npm test -w templates/third-person npm run dev -w templates/third-person ``` Walk up to the lever. The HUD's top-left rows gain `prompt E: pull lever`, and pressing `E` starts the door sliding with no key in your pocket. Add the case to `tests/game.test.ts` the way `refuses the door without the key` is written — place the hero, assert the prompt, tap `E`, assert `questState.escaped` after a hundred steps. ## See also - [Build your first adventure](../start/03-first-adventure.md) — where the interact cone and the tags come from - [Add NPC dialogue](./add-npc-dialogue.md) — the other thing `E` can start - [ECS and game code](../concepts/ecs.md) — what a tag is in this ECS - [Write a game system](./write-a-game-system.md) — if the behaviour outgrows one branch # FILE: docs/recipes/add-npc-dialogue.md # Add NPC dialogue ## Goal A third person in the arena with a script of their own: two lines and a yes/no question, their own name above the box, and their own expression per line. No change to the dialogue system. ## Files you will edit - `src/dialogue.json` - `src/prefabs.ts` ## Steps 1. **Write the script.** `src/dialogue.json` is a record of conversations keyed by name. Each has a `speaker`, a list of `lines`, and an optional `question` with a `yes` and a `no`. `face` names a preset in `FACES`, in `src/systems/dialogue.ts` — `neutral` or `smile` ship with the template. ```json { "smith": { "speaker": "Smith", "lines": [ { "text": "You will want the key before you try that door.", "face": "neutral" }, { "text": "I would fetch it myself, but I am busy.", "face": "smile" } ], "question": { "text": "Want me to point at the chest?", "face": "smile", "yes": { "text": "That one. The obvious one.", "face": "smile" }, "no": { "text": "Brave. Good luck.", "face": "neutral" } } } } ``` 2. **Tag an NPC with it.** Which script an NPC runs is a tag, so the level stays declarative and nothing is patched up after the spawn. In `src/prefabs.ts`: ```ts /** Tag: this NPC runs the `smith` conversation in `src/dialogue.json`. */ export const Smith: Record = {}; /** The smith: a third person to talk to. */ export const SmithPrefab = prefab({ name: 'smith', body: NPC_BODY, character: 'char.guide', components: [Interactable, Npc, Smith], }); ``` 3. **Point `scriptOf` at the tag.** One line in `src/systems/dialogue.ts` chooses the key: ```ts export function scriptOf(ctx: GameContext, npc: number): string { if (hasComponent(ctx.world, npc, Smith)) return 'smith'; return hasComponent(ctx.world, npc, Guide) ? 'guide' : 'wanderer'; } ``` 4. **Put them in the level.** A point in `src/arena.ts` and an entry in the `spawns` list in `src/game.ts`, lifted by `NPC_CENTRE` like the others. That is the whole recipe. Everything else is already done for you: `character.setExpression` and `character.lookAt` go out for every line, and `dialogueState.active` freezes the hero while anyone is talking. Both are the dialogue system's job, not the script's. Whether any of it is _drawn_ is the host's decision, not the script's. Out of the box the NPC is a capsule and the expression and the gaze are recorded on `adapter.animationOf(entity)` with one warning. Give the template a character bridge — [Give an NPC a face](./give-an-npc-a-face.md) — and the same commands move a real splat head, with nothing in `src/` changing. ## Verify ```sh npm test -w templates/third-person npm run dev -w templates/third-person ``` Walk up to the smith. The HUD shows `speaker Smith` and the first line, `E` advances, and the question offers `1 yes` and `2 no`. In the headless tests, `scripts.smith.lines` is readable straight out of the import and `dialogueState.script` is `'smith'` once the conversation starts. ## See also - [Build your first adventure](../start/03-first-adventure.md) — the whole loop - [Add an interactable](./add-an-interactable.md) — the other thing `E` can start - [Add a locomotion state](./add-a-locomotion-state.md) — what the hero does meanwhile - [Characters](../concepts/characters.md) — what expressions and gaze will drive # FILE: docs/recipes/tune-the-follow-camera.md # Tune the follow camera ## Goal A camera that sits where you want it: a longer or shorter boom, a different pivot height, a pitch range that suits your level, and smooth body turning when the camera moves behind the character. ## Files you will edit - `src/game.ts` - `src/systems/locomotion.ts` ## Steps 1. **Know who owns what.** The guest states an intent; the host owns the arm. `ctx.camera.follow(hero, { yaw, pitch, distance, height })` writes one `camera-state` record — the orbit pivot, the direction the player is looking, the boom length — and the engine builds a spring arm from it, probes from the pivot towards where the camera wants to sit, and pulls the boom in when a wall is in the way. You never write the collision. 2. **Change the numbers.** Everything is a rule in `src/game.ts`, and the declarative `player` block has to agree with it, because the engine re-states the rig after every user system: ```ts player: { prefab: HeroPrefab, spawn: HERO_SPAWN, camera: 'thirdPerson', distance: 3, // a tighter, more claustrophobic boom height: 0.25, // above the body centre: chest height on the 1.73 m rig sensitivity: 0.0018, }, rules: { cameraDistance: 3, cameraHeight: 0.25, cameraPitch: -0.3, // resting: a little above, looking down cameraMinPitch: -1.0, // how far the camera may rise cameraMaxPitch: 0.45, // how far it may drop and look up }, ``` Keep `distance`/`height` and `cameraDistance`/`cameraHeight` the same number. They are the same boom stated twice, and `tests/game.test.ts` asserts the result. 3. **Tune body turning.** The shared SDK controller follows these rules in `src/game.ts`: ```ts idleTurnThreshold: Math.PI / 2, idleTurnRate: 4.5, moveTurnRate: 10, acceleration: 10, deceleration: 14, ``` Small stationary orbits leave the body facing alone. Once the camera passes the threshold, the body completes its turn smoothly. While moving, it faces its camera-relative travel direction. The `aosrig_v0` model faces +Z, so its initial visual yaw is pi when the camera starts on +Z. 4. **Keep the shared controller.** `src/systems/locomotion.ts` constructs `createThirdPersonController()` from the SDK. It clamps the camera look accumulator in place and reuses a follow-options object every frame. Keep `controller.reset(ctx)` in game init so restart restores the camera, facing, acceleration and jump state together. ## Verify ```sh npm test -w templates/third-person npm run dev -w templates/third-person ``` Walk the hero into a corner and hold the mouse so the camera swings into the wall: the boom shortens instead of the wall filling the screen. The headless test `rides a spring arm behind the hero` asserts `armLength` and `offset.y` straight out of `output.camera`, so a mismatched pair fails before you look at it. ## See also - [Build your first adventure](../start/03-first-adventure.md) — the template this tunes - [Add a locomotion state](./add-a-locomotion-state.md) — the other half of the same system - [Read player input](./read-player-input.md) — where the mouse delta comes from - [The engine loop](../concepts/engine-loop.md) — why the camera is interpolated, not snapped # FILE: docs/recipes/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/use-the-sample-character.md # Use the sample character ## Goal An entity that was a placeholder capsule is drawn as the `aosrig_v0` sample character — a skinned body with a GNM head — and idles, walks and runs from the character state your game already publishes. It works on WebGPU and on the WebGL fallback, and nothing per frame crosses the wasm boundary for it. ## Files you will edit - `src/assets.json` - `src/prefabs.ts` ## Steps 1. **Declare the rig in the manifest.** `gameable/aosrig` ships one GLB with the skeleton, the skinning and four clips (`idle`, `walk`, `run`, `wave`). A `character` entry whose `rig.backend` is `skinned` says "the `src` is that GLB". The templates resolve `@aosrig/` to the bundled URL the same way they resolve `@placeholder/`: ```json { "id": "char.hero", "type": "character", "src": "@aosrig/aosrig_v0.glb", "tags": ["character"], "rig": { "backend": "skinned" } } ``` Several prefabs can share one entry: the GLB is parsed once per URL and cloned per spawn. 2. **Name it on the prefab.** `character` makes `ctx.spawn` emit a `spawn-character` command beside the entity's body. The host stands the rig on the body's sole — it knows the capsule's centre is 1.15 m above the feet — and drops the placeholder the moment the mesh is on screen. In `src/prefabs.ts`: ```ts import { Player, prefab } from 'gameable'; export const HeroPrefab = prefab({ name: 'hero', body: { shape: 'capsule', dims: [0.35, 0.8], kind: 'character', mass: 75 }, character: 'char.hero', components: [Player], }); ``` The rig faces `+Z`. A system that moves the entity should also write its facing into `Transform.qx..qw` — the third-person template turns the hero toward its heading, the FPS template points enemies at the player — or the character walks sideways. 3. **Keep publishing state.** The blend picks idle, walk or run from the planar speed in `character.setState(entity, name, vx, vy, vz, grounded)`; the name is your vocabulary and the velocity is what the animator reads. A clip that is not locomotion, such as `wave`, is played by name: ```ts import { character } from 'gameable'; character.setClipWeights(entity, ['wave'], [1], 1); ``` Send it once when the state changes, not every frame. ## Verify ```sh npm run dev -w templates/third-person ``` The hero stands on the floor with its feet on the arena, not hovering and not sunk to the knees, and turns to face the way it walks. `WASD` blends idle into walk; hold `Shift` and the walk blends into a run without the feet skating. Talk to the guide and its head turns toward you and stops near 45°. A capsule where the character should be means the GLB did not load: the bridge warns once with the URL it asked for. On a fresh clone the usual cause is a Git LFS pointer where the 3 MB file should be — `git lfs pull` fixes it, and `npm test -w packages/assets-aosrig` says so explicitly. ## See also - [Characters](../concepts/characters.md) — the three character paths and what each needs - [Animation](../concepts/animation.md) — the locomotion blend and the `extras.aos` clip contract - [Give an NPC a face](./give-an-npc-a-face.md) — the GNM splat head, for a face that moves - `packages/assets-aosrig/README.md` — gameable/aosrig