# Moonspell
Source: https://docs.moonspell.app/
> The Moonspell engine is a React Native (Expo) project set up as a game runtime, one TypeScript codebase that plays in the browser, on iOS and on Android, with a WebGPU 3D renderer on every platform.
The Moonspell engine is a React Native project set up to run games. You write one game in TypeScript and React, and the same code plays in a browser, on iOS and on Android. The 3D scene is drawn with WebGPU on every platform: three.js and React Three Fiber on top, react-native-webgpu on the phones, the browser's own WebGPU on the web.
If you know React, you already know how a Moonspell game is written. A game is a component. The scene is JSX. The HUD is React Native views. The game loop is a `useFrame` callback.
## What the engine is
The engine is an Expo app (Expo SDK 58, React Native 0.88) whose root component is a game. It comes as a template folder, `templates/crescent-game/`, that you copy and make your own. There is no npm package to install and no editor to learn: the engine is source code in `src/engine/`, and your game is source code in `src/game/`.
Inside, it wires a set of well-known libraries so they share one frame:
| Job | Library |
| --- | --- |
| 3D scene and frame loop | three.js (its WebGPU renderer) through React Three Fiber |
| GPU surface on the phones | react-native-webgpu (Dawn, Google's WebGPU implementation) |
| GPU surface in the browser | the browser's WebGPU (`navigator.gpu`) |
| HUD and 2D games | `@shopify/react-native-skia` and React Native views |
| Particles and compute shaders | TypeGPU, on the same GPU device as three.js |
| Touch and animation | react-native-gesture-handler, Reanimated |
| Sound | react-native-audio-api (a Web Audio API), expo-audio |
| Physics | cannon-es |
| Running in a browser | react-native-web |
The engine's own code is the glue and the game parts: a canvas that hosts the 3D scene, multi-touch controls that also answer to a keyboard, a HUD kit, an audio mixer, a 2D layer, saves, leaderboards and rooms. How these fit in one frame is on [How it works](https://docs.moonspell.app/start/how-it-works.md).
## The stack
```text
+------------------------------------------------------------------+
| Your game: src/game/index.tsx |
| a React component, or one config for a genre module |
+------------------------------------------------------------------+
| Genre modules: racing, puzzle, shooter, kingdom |
+------------------------------------------------------------------+
| Engine (src/engine): EngineCanvas, controls, HUD, audio mixer, |
| physics, 2D layer, character, combat, bots, saves, net, turns |
+----------------+---------+---------+--------------+--------------+
| three.js + R3F | Skia | TypeGPU | Reanimated + | audio-api, |
| (WebGPU) | | | gestures | cannon-es |
+----------------+---------+---------+--------------+--------------+
| React Native 0.88 + Expo SDK 58 (react-native-web in a |
| Hermes on the phones browser) |
+----------------------------------+-------------------------------+
| iOS, Android | Browser |
| react-native-webgpu (Dawn) | navigator.gpu |
+----------------------------------+-------------------------------+
```
## Why React Native for games
- **One language and one component model.** Game logic, the scene, the HUD and the menus are all TypeScript and React. A menu is a view, a spaceship is a ``, and both re-render from the same state.
- **A real native runtime on the phones.** The game runs on Hermes inside a native app, draws through a native WebGPU implementation, and reads multi-touch through a native gesture system. It is not a web page in a wrapper.
- **The web from the same source.** Expo builds the same project for the browser with react-native-web. You get a static site you can host anywhere, with no second codebase.
- **The ecosystem.** npm, three.js and its addons, Expo's modules, and the tooling you already use for React: TypeScript, Metro, Node's test runner.
The cost is that a game cannot add native code of its own in the Moonspell app, and a phone build is a normal Expo development build. [The game contract](https://docs.moonspell.app/start/game-contract.md) lists the rules that follow from that.
## What you get on top
- **Engine pieces.** A WebGPU canvas with sky, sun and post-processing; touch controls; a HUD kit with icons, fonts and rolling counters; an audio mixer; physics; GPU particles; a 2D layer; a character controller and cameras; a combat kit; bots; saves and progression; leaderboards, presence and rooms; a turns kit with a computer opponent; a story kit. See the [engine overview](https://docs.moonspell.app/engine/overview.md).
- **Four genre modules.** A whole game from one config: an arcade racer, a grid puzzle (block, merge, match, sort), an arena shooter, and a story-led kingdom builder. See [Game modules](https://docs.moonspell.app/modules/overview.md).
- **Eleven example games.** Each is one file you can copy over the game and change. See [Examples](https://docs.moonspell.app/examples.md).
- **A backend scaffold.** A Cloudflare Worker with Durable Objects for leaderboards, rooms, synced saves and an AI opponent. See [Backend](https://docs.moonspell.app/backend/overview.md).
- **A repo shaped for coding agents.** Every game carries `CLAUDE.md`, `AGENTS.md`, 24 skills and agent-facing docs. These docs are also published as `llms.txt`, as Markdown per page, and through a docs MCP server. See [Build with AI](https://docs.moonspell.app/ai/overview.md).
## A taste
This is a complete game. It is the Moon Blocks example (`examples/moon-blocks/index.tsx`): a block puzzle with a title screen, a HUD, a pause menu, results, sounds and a leaderboard, all from the puzzle module.
```tsx
import { definePuzzleGame } from '@/engine/modules/puzzle';
export default definePuzzleGame({
id: 'moon-blocks',
title: 'Moon Blocks',
description: 'Drag blocks onto the board and clear lines under the moon.',
pack: 'block',
mode: 'endless',
board: { rows: 8, cols: 8 },
block: { pieces: 'classic', tray: 3 },
theme: {
background: '#0B0D24',
board: '#161A3D',
cell: '#20264F',
accent: '#7EE0FF',
palette: ['#FF6B8B', '#FFB35C', '#FFE066', '#5BE38E', '#5CC8FF', '#9D7BFF'],
tileStyle: 'glossy',
},
});
```
When no module fits, you write the game yourself from the engine pieces. [Your first game](https://docs.moonspell.app/start/first-game.md) builds a small 3D one in about 170 lines.
## The engine and the Moonspell app
This is the engine behind the Moonspell app, where players describe a game in a chat and a coding agent builds it. The engine, the template, the examples, the skills, the server and these docs are open source under the MIT License. The source of the phone app and of the `crescent` CLI is private; the CLI is handed out in built form to developers who publish to the app. See [What is open and what is private](https://docs.moonspell.app/start/open-source.md).
There are two ways to build a game:
| Path | What you need | Where your game ends up |
| --- | --- | --- |
| **Engine only, plain Expo** | A clone of this repository. No account, nothing from Moonspell. | An Expo app you own: a static web build, or your own iOS and Android builds. [Quickstart](https://docs.moonspell.app/start/quickstart.md) |
| **Publish to Moonspell, local developer mode** | The starter and the built `crescent` CLI from moonspell.app, and a Moonspell account. | The Moonspell app on iPhone and Android and the web, public at once, with a share link. [Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md) |
## Where to go next
| If you want to | Read |
| --- | --- |
| Run a game in your browser now | [Quickstart](https://docs.moonspell.app/start/quickstart.md) |
| Build on your computer and publish to the Moonspell app | [Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md) |
| Build a small game from scratch | [Your first game](https://docs.moonspell.app/start/first-game.md) |
| Understand how React Native produces a frame | [How it works](https://docs.moonspell.app/start/how-it-works.md) |
| Know what is in a game repo | [Project structure](https://docs.moonspell.app/start/project-structure.md) |
| Look up an engine piece | [Engine overview](https://docs.moonspell.app/engine/overview.md) |
| Make a racer, puzzle, shooter or kingdom game | [Game modules](https://docs.moonspell.app/modules/overview.md) |
| Start from a finished game | [Examples](https://docs.moonspell.app/examples.md) |
| Put scores, rooms and saves online | [Backend](https://docs.moonspell.app/backend/overview.md) |
| Let Claude Code or another agent build it | [Build with AI](https://docs.moonspell.app/ai/overview.md) |
| Ship to the web, iOS or Android | [Web](https://docs.moonspell.app/platforms/web.md), [iOS](https://docs.moonspell.app/platforms/ios.md), [Android](https://docs.moonspell.app/platforms/android.md) |
| See how the hosted product works | [Architecture](https://docs.moonspell.app/moonspell/architecture.md) |
| Contribute | [Contributing](https://docs.moonspell.app/contributing.md) |
---
# Quickstart
Source: https://docs.moonspell.app/start/quickstart
> Clone the repository, install the game template, run the starter game Sunset Sprint in a browser with npm run web, make a static web build, run an example, and find the steps for iOS and Android.
This page gets the starter game, **Sunset Sprint**, running in your browser. It takes one clone, one install and one command. Then it shows how to make a static web build, swap in an example game, and where to go for phones.
Everything here is plain Expo. You do not need the Moonspell app or any Moonspell account. This is the "engine only" path; the other one is below ([What is open and what is private](https://docs.moonspell.app/start/open-source.md#two-ways-to-build-a-game)).
## Prerequisites
- **Node 24 and npm.** Tested with Node 24.18.0 and npm 11.16.0.
- **Git.**
- **A browser with WebGPU.** Tested in Google Chrome 157 on macOS. The engine asks players for "the latest Chrome, Edge or Safari"; those other browsers, Firefox, Windows and Linux have not been tested yet. There is no WebGL fallback: a browser without WebGPU shows a notice instead of the game.
## 1. Get the code
```bash
git clone https://github.com//moonspell.git
cd moonspell/templates/crescent-game
```
`templates/crescent-game/` is the game template: the engine in `src/engine/`, the starter game in `src/game/`, eleven example games in `examples/`. Every command on this page runs from that folder.
## 2. Install
```bash
npm install
```
Use a real install in this folder. A `node_modules` that is symlinked from somewhere else, or left over from an older checkout, breaks the web bundle (see [Troubleshooting](https://docs.moonspell.app/start/quickstart.md#troubleshooting)).
## 3. Run it in the browser
```bash
npm run web
```
This copies Skia's WebAssembly file (`canvaskit.wasm`) into `public/`, then starts Expo's dev server on `http://localhost:8081`. Open that URL. The first bundle takes about 15 seconds; later reloads are faster. To use another port, pass it through: `npm run web -- --port 8790`.
You should see Sunset Sprint: a coastal circuit at sunset, your car and three rivals, and a HUD with the lap, your position, your best lap and your speed.
Drive it:
| Input | Gas | Brake | Steer |
| --- | --- | --- | --- |
| Mouse or touch | hold **GAS** | hold **BRAKE** | hold the left and right arrow buttons |
| Keyboard | W or Up | S, Down or Space | A and D, or Left and Right |
The keyboard works because the engine's touch controls also listen to keys in a browser ([Input](https://docs.moonspell.app/engine/input.md)). The dev server also shows development extras that production builds hide: an **AUTO** button that drives the car for you, and a telemetry line.
Edit `src/game/index.tsx` and save: the page reloads with your change. Try `laps: 1`, or `timeOfDay: 'night'` in `theme`.
## 4. Make a static web build
```bash
npm run export:web
```
This writes a static site to `dist/`: `index.html`, one JavaScript bundle under `_expo/static/js/web/`, the game's assets, and `canvaskit.wasm`. Serve it with any static server and open it:
```bash
cd dist
python3 -m http.server 8765 --bind 127.0.0.1
# open http://127.0.0.1:8765/
```
The production build has no AUTO button and no telemetry line. Two things to know before you host it:
- The build uses absolute URLs (`/_expo/...`, `/assets/...`), so serve `dist/` at the root of a domain or subdomain.
- Serve `.wasm` files as `application/wasm`.
Hosting, sub-paths and browser support are on [Web](https://docs.moonspell.app/platforms/web.md).
## 5. Run it on a phone
```bash
npm run ios # expo run:ios
npm run android # expo run:android
```
Each makes a normal Expo development build of the game: Expo generates the native project, builds it and starts Metro. You need Xcode and CocoaPods for iOS, Android Studio and a JDK for Android, and you should first change the app ids in `app.json` (`expo.ios.bundleIdentifier` and `expo.android.package`) to your own.
These two commands are configured and the generated native projects were checked, but a build on a simulator or a device has not been run by the maintainers yet. The full steps and what is known are on [iOS](https://docs.moonspell.app/platforms/ios.md) and [Android](https://docs.moonspell.app/platforms/android.md).
## 6. Run an example
An example runs by becoming the game. Copy its file over `src/game/index.tsx`:
```bash
cp examples/tap-pop/index.tsx src/game/index.tsx
npm run web
```
Tap Pop is a 2D game: tap the drifting dots before the 30 seconds run out. To put Sunset Sprint back:
```bash
git checkout src/game/index.tsx
```
Tap Pop has been run in a browser; the other ten examples type-check with the template (`npm run typecheck` includes `examples/`) but have not all been run on the web. The list of examples and what each one shows is on [Examples](https://docs.moonspell.app/examples.md).
## Check your work
```bash
npm run typecheck # tsc --noEmit
npm test # the engine's unit tests (Node's test runner)
npm run export:web # the build players get; a Metro error here means the game will not open
```
Metro does not stop on type errors, so run `npm run typecheck` as well as the build.
## Troubleshooting
| What you see | Why | Fix |
| --- | --- | --- |
| "This browser can't run this game yet. Try the latest Chrome, Edge or Safari." | The browser has no `navigator.gpu`, or it returns no adapter | Use a current Chrome. Headless Chrome needs `--enable-unsafe-webgpu` |
| `Unable to resolve "react-native-web/dist/exports/..."` | `node_modules` is stale or symlinked from another folder | Delete `node_modules` and run `npm install` in this folder |
| `setup-skia-web` prints "Metro bundler not detected. Assuming the project is using Webpack." | Skia's setup script guesses the bundler | Nothing: the scripts pass the folder (`public`) explicitly |
| `npx expo-doctor` reports packages that do not match the SDK | The template pins exact versions on purpose ([The game contract](https://docs.moonspell.app/start/game-contract.md)) | Leave them as they are |
| A `404` for `/favicon.ico` | There is no favicon | Nothing, or add one to `public/` |
## Want your game in the Moonspell app?
The steps above never touch Moonspell. To publish a game to the Moonspell app on iPhone and Android and to the web, start from the starter instead: `npx https://moonspell.app/api/dev/cli/latest.tgz init my-game` downloads it with the built `crescent` CLI, and you sign in with a Moonspell account. See [Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md).
## Next steps
- [Your first game](https://docs.moonspell.app/start/first-game.md): build a small 3D game from scratch in `src/game/`.
- [How it works](https://docs.moonspell.app/start/how-it-works.md): what happens between `npm run web` and a frame on screen.
- [Project structure](https://docs.moonspell.app/start/project-structure.md): what is in the folder you ran, and what to rename before you make it your own.
- [Build with AI](https://docs.moonspell.app/ai/overview.md): open the folder in Claude Code and ask for a game.
---
# Your first game
Source: https://docs.moonspell.app/start/first-game
> Build Orb Hunt, a small 3D game, from scratch in src/game/index.tsx, step by step, with EngineCanvas, a joystick that also answers to the keyboard, a game loop in useFrame, a HUD and a finish screen, then run it in the browser.
This page builds a small but complete 3D game from an empty file: **Orb Hunt**. You roll a ball around a round floor with a thumb stick (or WASD in a browser) and catch as many glowing orbs as you can in 30 seconds. It has a start hint, a score, a timer, a finish card with Play again, and an exit button.
The whole game is one file, `src/game/index.tsx`, about 170 lines. It uses five engine pieces: `EngineCanvas` for the 3D scene, `Environment` and `PostFX` for light and glow, `Joystick` for input, and the HUD chrome (`TopBar`, `Banner`, `FinishCard`). The code on this page was type-checked with `npm run typecheck`, exported with `npm run export:web` and played in Chrome.
You need the template installed and running first: follow [Quickstart](https://docs.moonspell.app/start/quickstart.md) up to step 3.
## How a game plugs in
The template's `src/App.tsx` imports `src/game/index.tsx` and renders the `Component` of whatever that file default-exports. That export is a `GameDefinition`:
| Field | What it is |
| --- | --- |
| `id` | A URL-safe id, `orb-hunt` |
| `title` | The game's name |
| `description` | One line about the game |
| `Component` | The full-screen game. It receives `onExit`, which your exit button calls |
So making a game means replacing `src/game/index.tsx` with a file that default-exports a `GameDefinition`. The starter game is still in git; you can bring it back at any time with `git checkout src/game/index.tsx`.
Open `src/game/index.tsx`, delete everything in it, and write the code below in order, top to bottom. The steps follow the file.
## Step 1: imports
```tsx
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import type * as THREE from 'three';
import {
Banner,
EngineCanvas,
Environment,
FinishCard,
Joystick,
PostFX,
TopBar,
createCharacterInput,
makeRng,
resetCharacterInput,
type CharacterInput,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
```
`@/engine` is the template's engine (`src/engine/index.ts`; `@/` is an alias for `src/`). `useFrame` comes straight from React Three Fiber: inside `EngineCanvas` you use the normal R3F API. `three` is only imported for its types here; the template resolves it to three.js's WebGPU build.
## Step 2: the rules as numbers
```tsx
const ARENA = 4.5; // radius of the floor, in metres
const SPEED = 5; // metres per second with the stick at full tilt
const ROUND = 30; // seconds per round
const REACH = 1; // how close the ball must get to catch an orb
type Phase = 'ready' | 'playing' | 'finished';
```
Keep the tuning in named constants at the top. A round goes through three phases: `ready` (waiting for the first move), `playing`, and `finished`.
## Step 3: world state and HUD state
```tsx
/** Everything the loop changes every frame. A plain object, never React state. */
interface World {
phase: Phase;
x: number;
z: number;
orbX: number;
orbZ: number;
score: number;
timeLeft: number;
}
const newWorld = (): World => ({ phase: 'ready', x: 0, z: 0, orbX: 0, orbZ: -3, score: 0, timeLeft: ROUND });
/** What the HUD shows. It changes a few times a second at most. */
interface Hud {
phase: Phase;
score: number;
seconds: number;
}
const sameHud = (a: Hud, b: Hud) => a.phase === b.phase && a.score === b.score && a.seconds === b.seconds;
```
This split is the most important habit in an engine game. The ball's position changes 60 times a second; putting it in React state would re-render the whole scene every frame. So the loop works on a plain mutable object, `World`, kept in a ref. React state holds only what the HUD prints (the phase, the score, the whole seconds left), and it is updated only when one of those values changes. `sameHud` is that check.
## Step 4: the scene component
```tsx
interface SceneProps {
input: CharacterInput;
world: React.RefObject;
onHud: (hud: Hud) => void;
}
/** The 3D scene and the game loop. Everything here renders inside EngineCanvas. */
const Scene = ({ input, world, onHud }: SceneProps) => {
const ball = useRef(null);
const orb = useRef(null);
const rng = useMemo(() => makeRng(7), []);
```
`Scene` will render inside `EngineCanvas`, so it can call `useFrame`. It gets three things from the screen above it: the `input` object the joystick writes into, the `world` ref, and `onHud` to report what the HUD should show. `ball` and `orb` are refs to the two meshes so the loop can move them without React. `makeRng(7)` from the engine is a small seeded random number generator: the orbs appear in the same places every round, which makes the game easy to test.
## Step 5: the loop, moving the ball
```tsx
useFrame((state, delta) => {
const w = world.current;
const dt = Math.min(delta, 0.05);
if (w.phase !== 'finished') {
// The stick reads on the unit disc, +y up on screen: up rolls the ball away from the camera.
w.x += input.moveX * SPEED * dt;
w.z -= input.moveY * SPEED * dt;
const r = Math.hypot(w.x, w.z);
if (r > ARENA) {
w.x *= ARENA / r;
w.z *= ARENA / r;
}
if (w.phase === 'ready' && (input.moveX !== 0 || input.moveY !== 0)) w.phase = 'playing';
}
```
`useFrame` runs once per frame with the time since the last one, `delta`, in seconds. Capping it at 0.05 keeps a slow frame (or a tab coming back from the background) from teleporting the ball.
The joystick writes `moveX` and `moveY` on the unit disc, with +y meaning up on the screen. The camera looks down the -z axis, so pushing up should roll the ball toward -z: that is why `moveY` is subtracted. If the ball leaves the floor, it is scaled back onto the rim. The first push of the stick starts the round.
## Step 6: the loop, catching orbs and the clock
```tsx
if (w.phase === 'playing') {
w.timeLeft = Math.max(0, w.timeLeft - dt);
if (Math.hypot(w.x - w.orbX, w.z - w.orbZ) < REACH) {
w.score += 1;
const angle = rng() * Math.PI * 2;
const distance = 1.5 + rng() * (ARENA - 2);
w.orbX = Math.cos(angle) * distance;
w.orbZ = Math.sin(angle) * distance;
}
if (w.timeLeft === 0) w.phase = 'finished';
}
```
While the round plays, the clock counts down and a distance check decides a catch. A caught orb scores a point and jumps to a random spot on the floor, not too close to the centre or the rim. No physics engine is needed for a game this size; when you want things that bounce and topple, see [Physics](https://docs.moonspell.app/engine/physics.md).
## Step 7: the loop, drawing the result
```tsx
ball.current?.position.set(w.x, 0.5, w.z);
if (orb.current) {
orb.current.position.set(w.orbX, 0.8 + Math.sin(state.clock.elapsedTime * 3) * 0.15, w.orbZ);
orb.current.rotation.y += dt * 2;
}
onHud({ phase: w.phase, score: w.score, seconds: Math.ceil(w.timeLeft) });
});
```
The last part of the loop copies the world onto the meshes: the ball sits on the floor (its radius is 0.5), and the orb bobs and spins so it reads as a pickup. Then it reports the HUD values. `onHud` is called every frame, but as you will see in step 9 it only causes a re-render when a value changed.
## Step 8: the meshes
```tsx
return (
<>
>
);
};
```
This is plain React Three Fiber JSX: every three.js class is a lower-case element, constructor arguments go in `args`. The floor is a flat cylinder, the ball a sphere, the orb an octahedron with an emissive material, which the bloom in the next step turns into a glow. `castShadow` and `receiveShadow` work because `Environment` sets up the sun's shadows.
## Step 9: the screen
```tsx
/** The full-screen game: the canvas, the stick over it, the HUD on top. */
const OrbHunt = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const world = useRef(newWorld());
const [hud, setHud] = useState({ phase: 'ready', score: 0, seconds: ROUND });
// Called every frame; React only re-renders when a shown value changed.
const onHud = useCallback((next: Hud) => setHud((prev) => (sameHud(prev, next) ? prev : next)), []);
const restart = () => {
resetCharacterInput(input);
world.current = newWorld();
setHud({ phase: 'ready', score: 0, seconds: ROUND });
};
const status = hud.phase === 'ready' ? 'Catch the orbs' : hud.phase === 'finished' ? 'Time!' : `${hud.score} caught`;
```
`OrbHunt` is the `Component` of the game. It owns the three pieces of state from step 3:
- `input` comes from `createCharacterInput()`: a plain object with `moveX`, `moveY` and a few fields for look pads and buttons. The joystick writes into it; the loop reads it.
- `world` is the ref the loop mutates.
- `hud` is React state. When the updater in `onHud` returns the previous object unchanged, React skips the re-render, so the HUD updates about once a second (when the timer ticks) or when you score.
`restart` clears any held input, puts a fresh world in the ref and resets the HUD. Nothing in the scene needs to remount: the next frame draws the new world.
## Step 10: canvas, controls and HUD
```tsx
return (
{hud.phase === 'ready' && }
{hud.phase === 'finished' && (
)}
);
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#101114' },
});
```
The screen is three layers, in this order, because later siblings sit on top:
1. **`EngineCanvas`**: the WebGPU canvas with a React Three Fiber scene inside. The camera sits high and slightly behind the floor; with no rotation given, it looks at the origin. `Environment preset="sunset"` gives the sky, the sun with shadows, fill light and fog. `PostFX preset="glow"` adds bloom and a vignette. See [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md).
2. **`Joystick`**: a floating thumb stick. `zone="full"` lets a thumb start it anywhere below the top 14 percent of the screen; the base appears where the thumb lands. In a desktop browser it also answers to WASD and the arrow keys, and a mouse drag works like a thumb. See [Input](https://docs.moonspell.app/engine/input.md).
3. **The HUD**, a full-screen view with `pointerEvents="box-none"` so touches that miss its buttons fall through to the joystick. `TopBar` has the exit button, the title, the status line and the timer. `Banner` shows the start hint. `FinishCard` shows the result with **Play again** and **Back to games**. See [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md).
Both exits call `onExit`. In the template's `App.tsx` that unmounts the game (its loop and sounds stop) and shows a "Play Orb Hunt" button. Inside the Moonspell app it returns the player to the app.
## Step 11: the definition
```tsx
const game: GameDefinition = {
id: 'orb-hunt',
title: 'Orb Hunt',
description: 'Roll the ball into as many glowing orbs as you can in 30 seconds.',
Component: OrbHunt,
};
export default game;
```
The default export is what `App.tsx` renders. The title also names the Play button after an exit.
## Run it
Check the types, then start the dev server:
```bash
npm run typecheck
npm run web
```
Open `http://localhost:8081`. You should see a slate-blue floor under a grey sunset sky, a pale ball in the middle, a glowing gold orb ahead of it and **Move to start** across the screen. Press W (or drag up from the middle of the screen with the mouse) and the ball rolls into the orb: the status line says `1 caught` and the orb jumps somewhere else. After 30 seconds the finish card appears.
The console shows two `THREE.Clock: This module has been deprecated` warnings; they come from three.js and are harmless.
To check the build players would get, run `npm run export:web` and serve `dist/` as in [Quickstart](https://docs.moonspell.app/start/quickstart.md#4-make-a-static-web-build).
## The whole file
```tsx
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import type * as THREE from 'three';
import {
Banner,
EngineCanvas,
Environment,
FinishCard,
Joystick,
PostFX,
TopBar,
createCharacterInput,
makeRng,
resetCharacterInput,
type CharacterInput,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
const ARENA = 4.5; // radius of the floor, in metres
const SPEED = 5; // metres per second with the stick at full tilt
const ROUND = 30; // seconds per round
const REACH = 1; // how close the ball must get to catch an orb
type Phase = 'ready' | 'playing' | 'finished';
/** Everything the loop changes every frame. A plain object, never React state. */
interface World {
phase: Phase;
x: number;
z: number;
orbX: number;
orbZ: number;
score: number;
timeLeft: number;
}
const newWorld = (): World => ({ phase: 'ready', x: 0, z: 0, orbX: 0, orbZ: -3, score: 0, timeLeft: ROUND });
/** What the HUD shows. It changes a few times a second at most. */
interface Hud {
phase: Phase;
score: number;
seconds: number;
}
const sameHud = (a: Hud, b: Hud) => a.phase === b.phase && a.score === b.score && a.seconds === b.seconds;
interface SceneProps {
input: CharacterInput;
world: React.RefObject;
onHud: (hud: Hud) => void;
}
/** The 3D scene and the game loop. Everything here renders inside EngineCanvas. */
const Scene = ({ input, world, onHud }: SceneProps) => {
const ball = useRef(null);
const orb = useRef(null);
const rng = useMemo(() => makeRng(7), []);
useFrame((state, delta) => {
const w = world.current;
const dt = Math.min(delta, 0.05);
if (w.phase !== 'finished') {
// The stick reads on the unit disc, +y up on screen: up rolls the ball away from the camera.
w.x += input.moveX * SPEED * dt;
w.z -= input.moveY * SPEED * dt;
const r = Math.hypot(w.x, w.z);
if (r > ARENA) {
w.x *= ARENA / r;
w.z *= ARENA / r;
}
if (w.phase === 'ready' && (input.moveX !== 0 || input.moveY !== 0)) w.phase = 'playing';
}
if (w.phase === 'playing') {
w.timeLeft = Math.max(0, w.timeLeft - dt);
if (Math.hypot(w.x - w.orbX, w.z - w.orbZ) < REACH) {
w.score += 1;
const angle = rng() * Math.PI * 2;
const distance = 1.5 + rng() * (ARENA - 2);
w.orbX = Math.cos(angle) * distance;
w.orbZ = Math.sin(angle) * distance;
}
if (w.timeLeft === 0) w.phase = 'finished';
}
ball.current?.position.set(w.x, 0.5, w.z);
if (orb.current) {
orb.current.position.set(w.orbX, 0.8 + Math.sin(state.clock.elapsedTime * 3) * 0.15, w.orbZ);
orb.current.rotation.y += dt * 2;
}
onHud({ phase: w.phase, score: w.score, seconds: Math.ceil(w.timeLeft) });
});
return (
<>
>
);
};
/** The full-screen game: the canvas, the stick over it, the HUD on top. */
const OrbHunt = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const world = useRef(newWorld());
const [hud, setHud] = useState({ phase: 'ready', score: 0, seconds: ROUND });
// Called every frame; React only re-renders when a shown value changed.
const onHud = useCallback((next: Hud) => setHud((prev) => (sameHud(prev, next) ? prev : next)), []);
const restart = () => {
resetCharacterInput(input);
world.current = newWorld();
setHud({ phase: 'ready', score: 0, seconds: ROUND });
};
const status = hud.phase === 'ready' ? 'Catch the orbs' : hud.phase === 'finished' ? 'Time!' : `${hud.score} caught`;
return (
{hud.phase === 'ready' && }
{hud.phase === 'finished' && (
)}
);
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#101114' },
});
const game: GameDefinition = {
id: 'orb-hunt',
title: 'Orb Hunt',
description: 'Roll the ball into as many glowing orbs as you can in 30 seconds.',
Component: OrbHunt,
};
export default game;
```
## Make it yours
- **Tune it.** Change `ARENA`, `SPEED`, `ROUND` and `REACH`. A bigger arena needs the camera further back: the screen is portrait, so the view is narrow.
- **Add sound.** Put a short `.wav` in `assets/`, load it with `const sfx = useOneShots({ pop: require('../../assets/sfx/pop.wav') })` (Tap Pop's sound, already in the template) and call `sfx.play('pop')` on a catch, or use the audio mixer for positional sound ([Audio](https://docs.moonspell.app/engine/audio.md)).
- **Add juice.** A camera shake or a flash on every catch: [Game feel](https://docs.moonspell.app/engine/game-feel.md).
- **Keep a best score.** `useLeaderboard` works on the device with no backend ([Net](https://docs.moonspell.app/engine/net.md)); saves are on [Saves](https://docs.moonspell.app/engine/saves.md).
- **Name it.** The browser tab and the phone app take their name from `expo.name` in `app.json` (it says "Sunset Sprint"). The other names to change are on [Project structure](https://docs.moonspell.app/start/project-structure.md).
- **Update `playtest.json`.** The template's playtest drives Sunset Sprint's buttons. If you use playtests, rewrite it for your controls ([GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md)).
Before you share the game, read [The game contract](https://docs.moonspell.app/start/game-contract.md): the rules that keep it playing on the web, iOS and Android.
---
# Publish to the Moonspell app
Source: https://docs.moonspell.app/start/publish-to-moonspell
> Local developer mode, step by step - build a Moonspell game on your own computer with your own coding agent, then publish it to the Moonspell app on iPhone and Android and to the web with the built crescent CLI.
Local developer mode lets you build a game on your own computer with the coding agent you already use (Claude Code, Codex, Cursor or another), on your own subscription, and publish it to the Moonspell app. Moonspell runs no model for this and charges nothing. When the game plays, one command publishes it to the Moonspell app on iPhone and Android and to the web, and gives you a link and a QR code to play it on your phone.
Use this path when you want your game in the Moonspell app. To use the engine on its own, with no account, follow [Quickstart](https://docs.moonspell.app/start/quickstart.md) instead. [What is open and what is private](https://docs.moonspell.app/start/open-source.md) explains how the two paths relate.
## What you need
- Node.js 20 or newer, npm and git.
- A Moonspell account with Google. You sign in on moonspell.app; the CLI opens the page for you.
- A coding agent that can run commands in a folder. MCP support helps but is not required.
## Start a new game
```bash
npx https://moonspell.app/api/dev/cli/latest.tgz init my-game
cd my-game
```
`init` downloads the starter, installs its packages and makes the folder a git repo. The starter is the open-source game template (the engine, the examples, the skills) plus agent instructions and settings for this mode. The `crescent` CLI comes with it as a dev dependency, in built form; its source is private.
Open the folder with your agent and say what game you want: "a one-thumb endless runner on rooftops at night", "chess against a cheeky computer", "a racing game with five rivals in the rain".
The agent reads `AGENTS.md` (Claude Code reads the identical `CLAUDE.md`) and from there:
1. Signs you in. It gives you a link and a code: open the link, sign in with Google and approve the code.
2. Registers the game on your account and connects the folder to the game's private repo on Moonspell.
3. Builds the game, and saves every change to that repo. A save is private: nobody else sees it.
4. When the game plays from start to finish, tells you that publishing makes it public and asks you to go ahead.
5. Publishes: builds it for iPhone, Android and the web, and answers with the share link `https://moonspell.app/g/`, a QR code, and the game's page for developers, `https://moonspell.app/dev/`.
Scan the QR code with your phone: the link opens the game in the Moonspell app, or in its App Clip on an iPhone without the app. The game also shows in your own games in the app.
While you work, `npm run web` shows the game in your browser, as in the [Quickstart](https://docs.moonspell.app/start/quickstart.md#3-run-it-in-the-browser).
> **Warning: Every published version is public at once**
>
> It is in the Moonspell feed for every player, anyone with its link can play it, and other players can build on it in the app ([community builds](https://docs.moonspell.app/moonspell/community-builds.md)), like any game made in the app. There is no private or unlisted version. Your repo stays private.
## Continue a game you made in the app
On the web, each of your games has a **Continue building on your PC** button. It gives you a prompt to paste into your agent, which runs `npx continue `: that downloads the game's source into a new folder and adds the files listed below. The game already has players, so every version you publish from there is an update they all get.
For a game made in the app, `continue` adds the files the game lacks. `AGENTS.md` and `CLAUDE.md` replace the game's copies while those are still the template's; a copy the builder changed is kept as `AGENTS.game.md` or `CLAUDE.game.md`, and the agent reads both. `.claude/settings.json` keeps the game's own settings and gains the permissions and the save hook. This works for any game of yours, wherever it was made, and for a game you were invited to build.
## Start from the Moonspell MCP server
If your agent is connected to the [Moonspell MCP server](https://docs.moonspell.app/ai/mcp.md), its `moonspell_start` tool gives you the command for your case, with a one-time sign-in code (it works once, for 5 minutes), so you do not sign in again in the browser:
| Case | Command |
| --- | --- |
| A new game | `npx init --code `, optionally with `--title ""`. It signs in, unpacks, installs and registers the game. |
| A game with a repo | `npx continue [dir] --code ` |
| A game registered on your account that has no repo yet | `npx init --code --no-create`, then in the folder `npx crescent link ` and `npx crescent save` |
## What is in the folder
| Path | What it is |
| --- | --- |
| `AGENTS.md`, `CLAUDE.md` | The rules and the work loop for your agent (the same text). |
| `.claude/skills/*/SKILL.md` | How to build each kind of game, the engine's pieces, sound, UI, saves. Plain Markdown: any agent can read them. See [Skills](https://docs.moonspell.app/ai/skills.md). |
| `.claude/skills/moonspell-dev/` | Signing in, saving, publishing, links, invites, updates, and what is not available on your computer yet. Its `assets.cjs` makes placeholder models, images and sounds. |
| `.mcp.json` | Registers the Moonspell MCP server as `moonspell` (`https://mcp.moonspell.app/mcp`, signed in with Google). |
| `.claude/settings.json` | For Claude Code: permissions for the `crescent` commands and the MCP tools, and a Stop hook that saves at the end of each turn. |
| `.claude/hooks/moonspell-save.cjs` | That hook. It runs `crescent save --auto` in the background when something is left unsaved, is quiet otherwise, and never blocks the turn. |
| `.moonspell/game.json` | Which Moonspell game this folder is. Written when the game is created or linked; committed. |
| `.moonspell/overlay.json` | The list of files this mode adds, for `crescent continue` and `crescent sync`. |
| `src/game/`, `src/engine/`, `assets/`, `docs/`, `examples/` | The game, the engine, its assets, docs and example games. See [Project structure](https://docs.moonspell.app/start/project-structure.md). |
| `GDD.md` | The game's design document, which the agent writes and keeps up to date. See [GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md). |
## The `crescent` commands
`crescent` is the folder's own CLI (the `crescent-cli` dev dependency). It does everything that touches the folder's files and signs in on its own once (`npx crescent login`). Run it as `npx crescent `; run `npm install` first if `node_modules/` is missing, so `npx` finds it in the folder. `npx --no-install crescent mcp` starts a local MCP server with the same tools, for agents that cannot use the remote server.
| Command | MCP tool | What it does |
| --- | --- | --- |
| `npx crescent login`, `logout`, `whoami` | `moonspell_login`, `moonspell_status` | Sign in with Google, sign out, show who you are and which game this folder is |
| `npx crescent create --title ""` | `moonspell_create_game` | Register this folder as a new game |
| `npx crescent link ` | `moonspell_link_game` | Work on a game you collaborate on, or on a second computer |
| `npx crescent continue ` | `moonspell_continue` | Bring a game made in the app into a new folder |
| `npx crescent save -m ""` | `moonspell_save` | Commit and push to the game's private repo |
| `npx crescent publish -m ""` | `moonspell_publish` | Save (bringing in what collaborators pushed), then publish that commit as a public version: three builds, a link, a QR code, the developer page |
| `npx crescent links` | `moonspell_links` | The latest version's link, QR code and developer page |
| `npx crescent invite play`, `invite build` | `moonspell_invite` | A link to play, or to become a collaborator |
| `npx crescent sync` | `moonspell_sync_engine` | Bring the engine, skills and docs up to date, keeping files you changed |
| `npx crescent skills [name]` | `moonspell_skills`, `moonspell_skill` | The engine's skills from this folder: the list, or one skill's text |
`publish` needs an iOS build to succeed. The Android and web builds are best effort: a platform that fails to build is left out of that version, and the command says why.
## Your agent
- **Claude Code**: open the folder (`claude`). It asks once to trust the folder and to use the `moonspell` MCP server; say yes, and sign in with Google when it opens the browser. The Stop hook saves at the end of every turn as a safety net.
- **Codex, Cursor and others**: they read `AGENTS.md`, which tells them to load the engine's skills through the MCP server (`moonspell_skills`, then `moonspell_skill`) or with `npx crescent skills `. Add the remote server `https://mcp.moonspell.app/mcp` ([setup](https://docs.moonspell.app/ai/mcp.md)). An agent that cannot use a remote server runs the CLI's local one instead, configured in the folder as below (on Windows an MCP client may need `cmd /c npx ...`). Without MCP, the agent uses the commands.
```json
{
"mcpServers": {
"moonspell-local": {
"command": "npx",
"args": ["--no-install", "crescent", "mcp"]
}
}
}
```
## Not available on your own computer yet
- Generated 3D models, 2D art and sounds. The agent uses placeholders and the engine's fallbacks, and lists what the game wants in `.moonspell/models.json`, `sprites.json` and `sounds.json`. Files you make or own can go at the same paths.
- The game's own online backend (live leaderboards, "N playing now", online rooms, synced saves). Scores and progress stay on the phone for now, through the same engine API.
- Feed videos. Moonspell makes them from the game's `playtest.json`.
## How it works on the server
The routes, sign-in and uploads behind this mode are on [Local developer mode](https://docs.moonspell.app/moonspell/local-dev-mode.md), in "How Moonspell works".
---
# How it works
Source: https://docs.moonspell.app/start/how-it-works
> React Native as a game runtime, concretely, from the entry points and the Metro bundle to how EngineCanvas, three.js's WebGPU renderer and React Three Fiber produce a frame, how the HUD and input sit over the canvas, where platform differences live, and what the optional shell is.
A Moonspell game is an ordinary Expo app with an unusual root component. This page follows one game from its entry file to a frame on screen, on the phones and in a browser, and names the file that does each step. The engine pages hold the full reference; this page links to them.
## The short version
1. Expo starts the app from `index.ts` (phones) or `index.web.ts` (browser).
2. Both render `src/App.tsx`, which renders your game's `Component`.
3. Your game puts an `EngineCanvas` on screen. It creates a three.js `WebGPURenderer` on a WebGPU canvas and runs a React Three Fiber root inside it.
4. R3F's frame loop calls every `useFrame` callback, then three.js draws the scene, then the engine presents the frame.
5. The HUD and the touch controls are React Native views laid over the canvas. The controls write into plain objects that the loop reads each frame.
## Entry points
The template's `package.json` has `"main": "./index"`. Metro resolves platform extensions, so a browser build starts from `index.web.ts` and the phones from `index.ts`.
| File | What it does |
| --- | --- |
| `index.ts` | `registerRootComponent(App)`: the standard Expo entry |
| `index.web.ts` | `bootWeb(() => require('./src/App').default)`: the engine's browser boot runs first, then registers the same `App` |
| `src/App.tsx` | Wraps the game in `GestureHandlerRootView` and `KeyboardProvider`, hides the status bar, renders `game.Component` with `onExit`, and shows a "Play" button after an exit |
| `src/game/index.tsx` | Your game: it default-exports a `GameDefinition` (`id`, `title`, `description`, `Component`) |
`bootWeb` (`src/engine/web/boot.ts`) does three things before the game is evaluated: it checks for `navigator.gpu` and shows a notice if there is none, it loads Skia's WebAssembly build (CanvasKit, from `canvaskit.wasm` next to the page), and it installs a font fallback, because CanvasKit has no system fonts. The game is required inside the callback, not imported at the top, because Skia's web build reads CanvasKit the moment it is imported. See [Platform layer](https://docs.moonspell.app/engine/platform-layer.md#the-web-boot-srcengineweb).
`App.tsx` is also where exiting is wired: `onExit` unmounts the game, so its loop, physics and sounds stop, and then calls `exitToShell()`. Outside the Moonspell app that call does nothing on the phones and goes back one page in a browser when there is history.
## One bundle
Metro, through Expo, bundles the game for each platform from the same source. The template changes Metro in two places.
`metro.config.js`:
- `three` and `three/webgpu` resolve to `three/build/three.webgpu.js`, `three/tsl` to `three/build/three.tsl.js`, and `three/addons/*` to `three/examples/jsm/*`. There is exactly one three.js instance in the bundle, and `WebGPURenderer` and the node materials are always available. A library that imports `three` gets the same instance.
- `@react-three/fiber` resolves to its web build. The package's React Native entry targets expo-gl, which the engine does not use; `EngineCanvas` drives the R3F reconciler itself.
- `.glb`, `.gltf`, `.bin` and `.hdr` are asset types, so 3D models load with `require()`.
`babel.config.js` adds `unplugin-typegpu/babel`, which compiles TypeGPU functions marked `'use gpu'` from TypeScript to WGSL at build time.
The web build is one JavaScript file. That is why games use `require()` or static imports and never `import()`. See [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md#why-three-is-the-webgpu-build).
## How a frame is produced
`EngineCanvas` (`src/engine/render/EngineCanvas.tsx`) is the bridge between React Native and three.js:
1. It renders a `Canvas` from `react-native-webgpu`. On the phones that is a native view backed by Dawn. In a browser the same component renders an HTML `
}
/>
);
```
The template's `metro.config.js` adds `glb`, `gltf`, `bin` and `hdr` to Metro's asset extensions, so `require('….glb')` works. Put model files in `assets/models/`.
## `Model`
`Model` loads the file once per asset (cached), clones it per use, scales it, and puts its bottom (or centre) at the group origin. Your gameplay sizes (capsule radius, collision boxes) stay as they are.
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `src` | `number` | required | `require('../../assets/models/hero.glb')` |
| `height` | `number` | none | Scale so it stands this tall in metres (characters) |
| `size` | `number` | none | Scale so its longest side is this, in metres (props, vehicles). Ignored when `height` is set |
| `anchor` | `'feet'` or `'center'` | `'feet'` | Where the group origin sits |
| `animation` | `string` | the first clip | Clip to play by name; cross-fades (0.2 s) on change |
| `once` | `string[]` | none | Clips that play once and hold their last frame (a death, an attack) |
| `clips` | `Record` | none | Extra clips from other `.glb` files: `{ run: require('../../assets/models/hero.run.glb') }` |
| `timeScale` | `number` | `1` | Clip playback speed |
| `graph` | `AnimationGraph` or `(clipNames) => AnimationGraph` | none | Drive the clips with an animation graph instead of `animation` |
| `onPose` | `(scene, dt) => void` | none | Called every frame after the pose is applied: IK and pose tweaks |
| `fallback` | `React.ReactNode` | `null` | Drawn while loading, for a placeholder file, or when loading fails |
| `tint` | `string` | none | Multiplied into every material's colour (team colours from one model); materials are cloned per instance |
| `castShadow` | `boolean` | `true` | |
| `receiveShadow` | `boolean` | `false` | |
| `position`, `rotation`, `scale`, `visible` | | | On the outer group |
| `onLoad` | `(gltf) => void` | none | The loaded file |
| `collides` | `'box'`, `'sphere'`, `'hull'` | none | Inside ``: a body from its fitted bounds, or its convex hull (computed once per asset) |
| `body` | `'static'` or `'dynamic'` | `'static'` | A dynamic body falls and gets knocked over; then place the model directly under the scene |
| `mass` | `number` | `10` | kg, for a dynamic body |
| `onBody` | `(id: BodyId) => void` | none | The physics body's id |
| `level` | `Level` | none | With `collides` on static scenery: its bounds join `level.boxes` as a hidden box while mounted, so the character controller and bots collide with it |
The ref is a `ModelHandle`: `{ group, clipNames() }`.
```tsx
```
Generated characters from the Moonspell pipeline face +Z with their feet at the origin. If a model faces another way, turn it with `rotation`.
### Animation and IK exports
These are used by `CharacterMesh` and by `Model graph`. The character page covers them in use.
| Export | What it is |
| --- | --- |
| `AnimationGraph` | States, blend spaces, transitions, clip events and overlays |
| `locomotionGraph(clips, speeds)` | A ready graph for idle, walk, run, jump, fall, land |
| `blendWeights`, `findClip` | Helpers for blends and finding a clip by name |
| `threeClipPlayer(mixer, actions)` | Adapts three's mixer to the graph |
| `maskClip`, `stripRootMotion`, `clipRootSpeed`, `LAYER_MASKS` | Clip utilities (upper-body overlays, in-place clips, root speed) |
| `findRig`, `FootPlanter`, `lookAtTarget`, `applyTwoBoneIK`, `solveTwoBone` | Feet planted on slopes and steps, heads that turn to look |
## Loaders
| Export | What it does |
| --- | --- |
| `loadGlb(module)` | Loads and caches a bundled `.glb`; resolves to three's `GLTF`. The scene is shared: clone it before adding it (`Model` does) |
| `preloadGlb(module)` | Starts loading ahead of time; logs a warning on failure |
| `isPlaceholderGlb(gltf)` | True for a placeholder file (see below) |
Why a custom loader: three's `GLTFLoader` expects a browser image decoder for the textures inside a model, which React Native does not have. The engine registers a loader plugin that decodes every texture (PNG, JPEG, WebP) with Skia into a `DataTexture`. Meshes, skins, animations and PBR materials are three's own parser. On the phones the file's bytes come from `expo-asset` and `expo-file-system`; in a browser they are fetched from the asset's URL.
### Placeholders
In the hosted Moonspell pipeline, a game first references a model file that is a valid but empty placeholder `.glb` (marked with `asset.extras.crescentPlaceholder` in the glTF JSON); the real model is generated after the code pass and replaces the file. `Model`, `Scenery` and `Billboard` draw their `fallback` for a placeholder, so the game plays the same before and after. Outside Moonspell you usually add the real file directly, but keep a fallback anyway: it also covers loading time and load failures.
## `Scenery`
One static model placed many times. Each mesh part of the model becomes one `InstancedMesh`, so the draw cost does not grow with the number of items. Skinned (animated) meshes are skipped: characters go through `Model`.
```tsx
import React from 'react';
import { Scenery, type SceneryItem } from '@/engine';
const BROWNSTONE = require('../../assets/models/brownstone.glb');
const lots = [
{ x: -8, z: 0, width: 7, height: 12, side: -1, tint: '#E8D8C8' },
{ x: -8, z: -9, width: 8, height: 15, side: -1 },
{ x: 8, z: 0, width: 7, height: 11, side: 1 },
];
const row: SceneryItem[] = lots.map((lot) => ({
position: [lot.x, 0, lot.z],
rotationY: lot.side < 0 ? Math.PI / 2 : -Math.PI / 2,
size: [lot.width, lot.height, 10],
color: lot.tint,
}));
export const Street = () => (
(
))}
/>
);
```
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `src` | `number` | required | A static model |
| `items` | `SceneryItem[]` | required | Every place it stands |
| `stretch` | `boolean` | `true` | `size` fills each axis on its own; `false` keeps the model's proportions inside the box |
| `fallback` | `React.ReactNode` | `null` | Drawn while loading, for a placeholder, or on failure |
| `castShadow` | `boolean` | `false` | |
| `receiveShadow` | `boolean` | `true` | |
| `lit` | `number` | `0` | Let the model's own colours glow (0 to 1) so it reads at dusk or night; about 0.3 keeps the shading |
| `level` | `Level` | none | Make every item solid for the controller and bots (a hidden box per item while mounted). Item positions must then be world positions |
A `SceneryItem`:
| Field | Type | What it is |
| --- | --- | --- |
| `position` | `[x, y, z]` | Where the model's base centre goes |
| `rotationY` | `number`, optional | Turn in radians. Generated models face +Z; `Math.PI` faces -Z |
| `size` | `[w, h, d]`, optional | The box to fill, in metres, before the turn |
| `height` | `number`, optional | Keep proportions and stand this tall (ignored with `size`) |
| `color` | `string`, optional | Multiplied with the model's colours: one model, many looks |
Placement maths is exported for tests and your own colliders: `itemMatrix(item, bounds, stretch?, out?)`, `itemBox(item, bounds, stretch?)`, `itemScale(item, bounds, stretch?)` and `boundsOf(box3)`.
## `Billboard`
A sprite in the 3D scene: one cell of a sheet on a quad standing on its base, turning to face the camera. Until the sheet is real art (a 1 by 1 placeholder image), the `fallback` is drawn instead.
```tsx
import React from 'react';
import { Billboard } from '@/engine';
const CROPS = require('../../assets/sprites/crops.png');
export const Wheat = ({ x, z, stage }: { x: number; z: number; stage: number }) => (
);
```
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `source` | `number` | required | The `require()`d sheet |
| `cols`, `rows` | `number` | `1`, `1` | The grid |
| `frame` | `number` | `0` | Cell index, reading order |
| `position` | `[x, y, z]` | `[0, 0, 0]` | Base centre in metres |
| `size` | `number` | `1` | Height in metres (cells are square) |
| `face` | `'y'` or `'full'` | `'y'` | `y` spins around the vertical axis only (trees, crops); `full` faces the camera exactly (coins, icons) |
| `tint` | `string` | none | Multiplies the art's colours |
| `opacity` | `number` | `1` | |
| `fallback` | `React.ReactNode` | `null` | Drawn until the art is real |
| `visible` | `boolean` | `true` | |
`useSheetTexture(module)` returns the decoded sheet as a three `DataTexture` (shared by every billboard that uses it), or `null` while loading or for a placeholder. Use it for your own materials.
## Gotchas
- Textured models cost memory. One hero and a few props is a good budget on a phone; use `Scenery` for anything repeated instead of many `Model`s.
- Keep gameplay on capsules and boxes; the model is what is drawn. `collides` and `level` are the bridges when a model must be solid.
- With `Model level`, mount the model before building the bots' nav grid, or rebuild the grid after.
- Skinned meshes have frustum culling turned off (they move past their rest bounds), so many animated characters off screen still cost draw time.
- Basis and KTX2 textures are left to three's own plugin, which the engine does not set up. Use PNG, JPEG or WebP textures.
- Never build a model's path or URL by hand. Load it with `require()`: a web build names assets by address.
- Procedural shapes (`Extruded` from an outline, `Lathed` from a profile) and level pieces live in `level/` ([Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)).
---
# GPU effects
Source: https://docs.moonspell.app/engine/gpu-effects
> TypeGPU effects on three's own GPU device, Particles, Trail, Grass, LivelyGround, Water and GpuStrip, and how to write your own compute shader that fills buffers three draws.
The GPU folder (`src/engine/gpu/`, exported from `@/engine`) holds effects that move thousands of things without JavaScript per thing. They all follow one split: **three draws, TypeGPU simulates.** A TypeGPU compute shader, written in TypeScript with the `'use gpu'` directive, fills buffers that three's `WebGPURenderer` draws from in the same frame, on the same GPU device.
Use these for decoration that reacts to the game: fire, sparks, rain, magic, trails, a meadow, water. Gameplay never reads GPU state back: collisions and scoring stay in JavaScript.
## Smallest working example
A campfire: fire, smoke above it and sparks you can burst on demand.
```tsx
import React, { useRef } from 'react';
import { EngineCanvas, Environment, Particles, type ParticlesHandle } from '@/engine';
export const Campfire = () => {
const sparks = useRef(null);
// from gameplay: sparks.current?.burst(60, [0, 0.3, 0]);
return (
);
};
```
These components must be inside `EngineCanvas`: they read the renderer and its device.
## `Particles`
Particles are camera-facing soft discs (or streaks), drawn as instanced sprites from two storage attributes the compute shader writes. One draw call per ``.
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `preset` | `'fire'`, `'sparks'`, `'smoke'`, `'magic'`, `'rain'`, `'snow'`, `'dust'`, `'puff'`, `'vortex'` | `'fire'` | The emitter to start from |
| `count` | `number` | `256` | How many live at once |
| `origin` | `[x, y, z]` | `[0, 0, 0]` | Where they spawn, world space |
| `follow` | `RefObject` | none | Spawn at this object's world position instead (a car's exhaust, a glow around the player) |
| `emitting` | `boolean` | `true` | `false` stops new particles; the live ones finish and the system stays warm |
| `config` | `EmitterConfig` | none | A whole emitter instead of a preset plus overrides |
| `timeScale` | `number` | `1` | Simulation speed |
| any `EmitterConfig` field | | from preset | Overrides, below |
`EmitterConfig` fields (sizes in metres, speeds in m/s):
| Field | What it is |
| --- | --- |
| `spread` | Half size of the spawn box around the origin, `[x, y, z]` |
| `velocity`, `jitter` | Initial velocity and its random share |
| `gravity` | `[x, y, z]` acceleration |
| `drag` | Velocity damping per second (0 none, 1 strong) |
| `swirl` | Sideways swirl around the emitter's up axis |
| `life`, `lifeJitter` | Seconds a particle lives, and the random share |
| `sizeStart`, `sizeEnd` | Size over life |
| `stretch` | Height over width (rain streaks, sparks) |
| `colorA`, `colorB` | Colour over life |
| `additive` | Additive (glows) or normal blending (smoke, rain) |
| `opacity` | Peak opacity |
| `ground` | `{ y, bounce?, friction? }`: a floor at height `y`. `bounce` above 0 bounces (sparks); 0 lands them as fading dots (rain splashes) |
| `attractor` | `{ at?, strength?, vortex? }`: pull toward a point (default the origin; negative pushes) and spin around it |
The ref is a `ParticlesHandle` with `burst(count, at?)`: respawn `count` particles this frame, from `at` when given. An `emitting={false}` system plus `burst` is a burst-only effect (hits, crashes, coins). `puff` is made for that: kicked-up dust on landings and footsteps. `PARTICLE_PRESETS` holds every preset's values.
```tsx
```
## `Trail`
A ribbon behind a moving object (a light wall behind a bike, a skid line, a sword's arc), thinning and fading toward the tail. One mesh, a few dozen points updated on the CPU each frame.
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `follow` | `RefObject` | required | What leaves the trail |
| `length` | `number` | `48` | Points kept |
| `width` | `number` | `0.3` | Width at the head, metres |
| `color` | `string` | `'#FF4FD8'` | |
| `orientation` | `'vertical'`, `'flat'`, `'camera'` | `'vertical'` | A wall, a line on the ground, or a ribbon that faces the camera |
| `offsetY` | `number` | `0` | Lift above the follow point |
| `taper` | `boolean` | `true` | Thin toward the tail |
| `additive` | `boolean` | `true` | Glow or plain |
| `minDistance` | `number` | `0.08` | A new point only after moving this far |
| `opacity` | `number` | `0.85` | |
## `Grass` and `LivelyGround`
A meadow: thousands of blades swaying in a wind that travels across the field and parting around whoever walks through. A compute shader bends every blade each frame; one draw call for the blades. `LivelyGround` is the ground under it: soft colour patches, a fine speckle and a slow shimmer.
```tsx
import React, { useRef } from 'react';
import { Grass, LivelyGround } from '@/engine';
export const Meadow = ({ player }: { player: React.RefObject<{ x: number; z: number }> }) => (
<>
(player.current ? { x: player.current.x, z: player.current.z } : null)}
/>
>
);
```
| `Grass` prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `area` | `{ x?, z?, w, d }` | required | The rectangle the blades cover, centre and size in metres |
| `count` | `number` | `3000` | Blades: 1,000 for a yard, 3,000 to 6,000 for a field |
| `height` | `number` | `0.5` | Blade height |
| `width` | `number` | `0.09` | Blade width at the root |
| `colorA`, `colorB` | `string` | `'#4E9A3C'`, `'#C9C35A'` | Body and tip colours |
| `follow` | `() => ({ x, z })` | none | Follow mode: blades fill a `tile` square around this point and wrap as it moves, so a big field stays dense for the cost of one tile. `area` and `mask` are then ignored |
| `tile` | `number` | `24` | The follow tile's width |
| `clearings` | `number` | `0.35` | How much ground is open between tufts (0 to 1) |
| `wind` | `number` | `1` | 0 still, 1 breezy, 2 stormy |
| `windDir` | `[x, z]` | `[1, 0.3]` | Wind direction |
| `mask` | `(x, z) => boolean` | none | Grow only where this returns true (not on paths or water) |
| `maskKey` | `string` | `''` | Change it to re-scatter after the mask changes |
| `trample` | `() => ({ x, z }) or null` | none | Blades lean away from this point |
| `trampleRadius` | `number` | `1.3` | |
| `seed` | `number` | `3` | Scatter seed |
| `receiveShadow` | `boolean` | `true` | |
| `LivelyGround` prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `x`, `z` | `number` | `0` | Centre |
| `w`, `d` | `number` | required | Size in metres |
| `base` | `string` | required | The main green |
| `light` | `string` | a lighter `base` | The second green the patches drift to |
| `y` | `number` | `0.004` | Height of the plane, a hair above a plain ground |
| `receiveShadow` | `boolean` | `true` | |
## `Water`
Rivers, moats, ponds and sea: one plane with a stylised TSL material (drifting deep and shallow patches, toon ripple lines, sparkles, a foam band at the edges) and, when `ripples` is on, a 2D wave simulation in a TypeGPU compute shader (raindrops, splashes, a wake). No reflection or refraction pass, so it stays cheap on a phone. It lives in `src/engine/render/Water.tsx` and is exported from `@/engine`.
```tsx
import React, { useRef } from 'react';
import { Water, type WaterHandle } from '@/engine';
export const Pond = () => {
const water = useRef(null);
// something fell in: water.current?.splash(2, -1);
return ;
};
```
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `x`, `z` | `number` | `0` | Centre |
| `w`, `d` | `number` | required | Size in metres |
| `y` | `number` | `0.02` | Surface height |
| `color` | `string` | required | The water's colour; the other tones are derived from it |
| `deep`, `shallow`, `foam` | `string` | derived | Override a derived tone |
| `speed` | `number` | `1` | Drift speed of the patches and lines |
| `scale` | `number` | `1` | Size of the patches and lines |
| `waves` | `number` | `0.04` | Slow heave height, metres |
| `foamWidth` | `number` | `0.06` | Foam band as a fraction of the shorter side (0 none) |
| `opacity` | `number` | `0.94` | |
| `style` | `'toon'` or `'soft'` | `'toon'` | Hard ripple lines and sparkles, or smooth highlights |
| `ripples` | `boolean` | `false` | The ripple simulation (one compute pass a frame) |
| `drops` | `number` | `2` | Raindrop splashes per second, with `ripples` |
| `rippleHeight` | `number` | `0.12` | Metres |
| `disturb` | `() => ({ x, z, strength? }) or null` | none | A moving source each frame (a wading hero) |
| `simulate` | `boolean` | `true` | Debug: keep the ripple attribute but never run the compute shader |
| `receiveShadow` | `boolean` | `true` | |
The ref is a `WaterHandle` with `splash(x, z, strength?)`. The ripple grid is capped at 16,384 cells, so very large water gets coarser ripples.
## `GpuStrip`
A small HUD strip drawn by a TypeGPU fragment shader on its own WebGPU canvas (outside the 3D scene): moving stripes whose speed and glow follow `energy`. The racing HUD uses it as a reactive bar.
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `energy` | `SharedValue` | required | 0 to 1, how energetic it animates |
| `colorA`, `colorB` | `[r, g, b]` (0 to 1) | violet, teal | The gradient |
| `style` | `ViewProps['style']` | height 28 | |
| `testID` | `string` | none | |
It wraps itself in `EngineErrorBoundary` and shows a flat bar if the GPU fails.
## Your own compute shader
For anything the presets do not cover (a flock, a cloth, a wave field), use the same split. The exports:
| Export | What it does |
| --- | --- |
| `useTypeGpuRoot()` | The TypeGPU root for the canvas's device (`null` before the renderer is ready). One per device, never destroyed: the device belongs to three |
| `attributeGpuBuffer(renderer, attribute)` | The `GPUBuffer` three created for a buffer attribute, or `null` until three has drawn with it once |
| `rendererDevice(renderer)` | The `GPUDevice` behind a `WebGPURenderer` |
| `typeGpuRootFor(device)` | The root for a device, created once |
The steps, as `Particles.tsx` does them:
1. Make a three `StorageInstancedBufferAttribute` (or `StorageBufferAttribute`, both from `three/webgpu`) and draw from it with a node material (`instancedBufferAttribute(attr)` in TSL).
2. In `useFrame`, poll `attributeGpuBuffer(renderer, attr)` until it is not `null`, then wrap it: `root.createBuffer(d.arrayOf(d.vec4f, n), gpuBuffer).$usage('storage').as('mutable')`.
3. Write the step with `tgpu.computeFn({ in: { gid: d.builtin.globalInvocationId }, workgroupSize: [64] })`, a function whose body starts with `'use gpu'`, and dispatch it each frame with `pipeline.dispatchWorkgroups(Math.ceil(n / 64))`. Any `useFrame` runs before three renders.
```ts
import tgpu, { d } from 'typegpu';
import * as std from 'typegpu/std';
import { attributeGpuBuffer, useTypeGpuRoot } from '@/engine';
```
The `'use gpu'` functions are compiled to WGSL at build time by `unplugin-typegpu/babel`, which the template's `babel.config.js` already loads.
## Gotchas
- Rules for `'use gpu'` code, or TypeGPU rejects the shader and the effect goes dark with an `[engine/gpu]` warning: vector maths goes through `typegpu/std` (`std.add`, `std.mul`, `std.mix`), scalars use plain operators; a `let` you reassign must hold a copy (`let pos = d.vec3f(buf.$[i].pos)`); guard the index against the count.
- `d.struct` field names must not be WGSL reserved words (`active`, `filter`, `type`, `target`, `self` and others). TypeGPU throws when the struct is built, at module load, so the whole game fails to open. Use names like `alive`, `mode`, `kind`.
- Never call `root.destroy()`: the device belongs to three. Destroy your own buffers on unmount.
- Budget: a few hundred particles for a local effect, 1,000 to 3,000 for weather. Each `` is one draw call, so prefer one bigger system over many small ones.
- `` makes additive particles and emissive materials bloom ([Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md)).
- In development, particle systems add a line to the debug overlay ([Utilities](https://docs.moonspell.app/engine/utilities.md)).
---
# Game feel
Source: https://docs.moonspell.app/engine/game-feel
> Camera shake, hit-stop, slow motion, a screen flash, squash and stretch, pops and tweens on any three object, and the juice presets that combine them, from GameFeel and FeelOverlay.
The feel folder (`src/engine/feel/`, exported from `@/engine`) makes moments in the 3D scene land: a hit that shakes the camera and freezes the scene for 60 ms, a landing that squashes the character, a death in slow motion. Every tool is a plain function you call from gameplay code, with no React state and nothing to pass down. For menus and the HUD, use the UI kit's motion pieces instead ([HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)).
## Smallest working example
Mount `GameFeel` once inside the canvas and `FeelOverlay` once over it, then call the tools from anywhere:
```tsx
import React, { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import type * as THREE from 'three';
import { EngineCanvas, FeelOverlay, GameFeel, juice, type GameScreenProps } from '@/engine';
export const Screen = (_props: GameScreenProps) => {
const enemy = useRef(null);
const onHit = () => juice('hit', { object3d: enemy.current ?? undefined });
return (
HIT
);
};
```
`juice('hit')` adds camera shake, a 60 ms hit-stop, a white flash, a squash on the object and a medium haptic.
## Setup components
### `GameFeel`
Mount once inside `EngineCanvas`. It installs a `FeelClock` as R3F's clock, so the `delta` of every `useFrame` (physics, particles, the character controller, animations, tweens) is scaled by the current slow motion or hit-stop. It steps the shared tweens and the shake, and applies the shake to the camera right before the draw and removes it right after, so every camera rig keeps its own position and damping.
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `shake` | `ShakeOptions` | see below | Tune the camera shake |
| `timeScale` | `number` | `1` | A permanent time scale under the timed effects (0.5 is half speed) |
`ShakeOptions`: `maxOffset` (metres at full trauma, 0.35), `maxRoll` (radians, 0.06), `decay` (trauma lost per second, 1.6, so a full shake lasts about 0.6 s), `frequency` (cycles per second, 28). `` turns shake off, for a settings toggle.
### `FeelOverlay`
Mount once over the canvas (after `EngineCanvas`, before the HUD). It is the full-screen layer `flash()` draws on, and it wires haptics (`expo-haptics`) for the juice presets. With the system's reduced motion setting on, flashes are shorter and dimmer, never off.
## The tools
| Function | What it does |
| --- | --- |
| `shake(amount)` | Add trauma: 0.2 a bump, 0.5 a crash, 0.9 an explosion. Adds up, capped at 1. The offset grows with trauma squared, so small values barely move the camera |
| `hitStop(ms = 60)` | Freeze the scene for real milliseconds. 40 to 90 ms reads as impact; more reads as a stall. The HUD keeps moving |
| `slowMotion(scale, ms)` | The scene at `scale` speed for real milliseconds. When effects overlap, the strongest (lowest) scale wins |
| `flash(color = '#ffffff', ms = 120, alpha = 0.4)` | A full-screen colour flash that fades over `ms`. Needs `FeelOverlay` |
| `squash(object, amount = 0.25, duration = 0.22)` | Wider and shorter, then back (landings) |
| `stretch(object, amount = 0.2, duration = 0.25)` | Taller and thinner, then back (jumps, launches) |
| `pop(object, amount = 0.3, duration = 0.3)` | Scale up, then settle (a pickup appearing) |
| `vanish(object, duration = 0.25)` | Shrink to nothing; returns a promise that resolves when gone |
`object` is any three `Object3D` (a mesh, a group, `CharacterMesh`'s group).
## `juice(name, overrides?)`
The presets combine the tools. Override any field per call, and pass `object3d` for the object effects:
```ts
import type * as THREE from 'three';
import { juice } from '@/engine';
export function onEnemyHit(enemyMesh: THREE.Object3D) {
juice('hit', { object3d: enemyMesh, shake: 0.8, hitStop: 0 });
}
export function onBarrelExploded() {
juice('explosion');
}
```
| Name | Shake | Hit-stop | Flash | Slow motion | Object | Haptic |
| --- | --- | --- | --- | --- | --- | --- |
| `bump` | 0.2 | | | | | light |
| `land` | 0.12 | | | | squash | light |
| `pickup` | | | white, 90 ms, 0.12 | | vanish | light |
| `hit` | 0.45 | 60 ms | white, 90 ms, 0.35 | | squash | medium |
| `explosion` | 0.9 | 80 ms | amber, 180 ms, 0.5 | 0.45 for 260 ms | | heavy |
| `death` | 0.6 | | red, 320 ms, 0.5 | 0.25 for 900 ms | squash | heavy |
A `JuiceRecipe` has `shake?`, `hitStop?`, `flash?: { color, ms, alpha }`, `slow?: { scale, ms }`, `object?` (`'squash'`, `'stretch'`, `'pop'`, `'vanish'`) and `haptic?` (`'light'`, `'medium'`, `'heavy'`). `JUICE` is the table.
## Tweens
`tweens` is the shared tween runner. It drives any number fields of any object: a `Vector3`, an `Euler`, a `Color`, a material, a light, or your own `{ hp }`.
```ts
import { tweens } from '@/engine';
import type * as THREE from 'three';
export function openDoor(door: THREE.Object3D, onOpen: () => void) {
tweens.sequence([
[door.rotation, { y: -1.4 }, { duration: 0.6, ease: 'cubicInOut' }],
onOpen,
[door.rotation, { y: 0 }, { duration: 0.8, delay: 1 }],
]);
}
export function fadeOut(material: THREE.MeshStandardMaterial, done: () => void) {
return tweens.to(material, { opacity: 0 }, { duration: 0.3, onComplete: done });
}
```
| Function | What it does |
| --- | --- |
| `tweens.to(target, props, options?)` | Animate the fields to these values |
| `tweens.from(target, props, options?)` | Start from these values and animate back to the current ones |
| `tweens.sequence(steps)` | Steps in order; a step is `[target, props, options?]` or a function to call at that point |
| `tweens.cancelAll()` | Stop every shared tween |
`TweenOptions`:
| Option | Default | What it does |
| --- | --- | --- |
| `duration` | `0.3` | Seconds |
| `ease` | `'cubicOut'` | An `EASE` name or your own function |
| `delay` | `0` | Seconds before it starts |
| `yoyo` | `false` | Go back to the start values after the end |
| `repeat` | `0` | Extra times to play (`Infinity` loops) |
| `onUpdate` | none | Called every step with the eased progress |
| `onComplete` | none | Called at the end (not when cancelled) |
Each call returns a `FeelTween`: `cancel()` stops where it is, `finish()` jumps to the end, `done` and `finished` (a promise that resolves on completion). A new tween on the same field takes over from the old one.
`EASE` names: `linear`, `quadIn`, `quadOut`, `quadInOut`, `cubicIn`, `cubicOut`, `cubicInOut`, `sineInOut`, `backIn`, `backOut`, `elasticOut`, `bounceOut` (the same names as the 2D layer's easings).
### Hooks
| Hook | What it does |
| --- | --- |
| `useTweens()` | A `TweenRunner` that belongs to one component: stepped with the scene, cancelled on unmount. Use it for an object's own idle motion (a lantern bobbing) |
| `useFeelClock()` | The canvas clock, to read the current `scale` or the scaled `elapsedTime` in a `useFrame` |
Lower-level exports: `TweenRunner`, `FeelClock`, `CameraShake`, `createFeel`, `getFeel` (for tests and custom setups).
## Gotchas
- One strong cue per moment, two at most. A hit is shake plus hit-stop; a death is slow motion plus flash. Everything on every event reads as noise.
- Keep slow motion short (400 ms or less) in play; longer only on a death or a win.
- Squash and stretch suit cartoon bodies and props; on a realistic body it looks like crumpling. Never on the level.
- Time effects are timed in real milliseconds and the shake decays in real time, so a hit-stop does not freeze a shake mid-jolt.
- There is one active feel state per app: a newly mounted `GameFeel` replaces the previous one, and `shake()` and the others act on it.
- `FeelOverlay` registers its flash once for the whole game, not on the feel state, so `flash()` and the flashes in the `juice` presets work whichever of `FeelOverlay` and `GameFeel` mounts first (in the usual layout the overlay mounts first: the canvas mounts its scene after the renderer starts).
- Pair a moment with a sound (the [audio mixer](https://docs.moonspell.app/engine/audio.md)) and with the HUD's `Bump` or floating numbers for the number that changed.
---
# The 2D layer
Source: https://docs.moonspell.app/engine/2d
> GameCanvas2D and the pieces a flat game uses, from stage units, pointer events and the fixed-step loop to drawing, tweens, particles, sprite sheets, tile maps, platformer and top-down bodies, and a 2D camera.
The 2D layer (`src/engine/2d/`, exported from `@/engine`) is a full-screen Skia canvas for flat games: puzzles, one-tap arcade, cards, word games, idle screens, platformers and top-down games. Use it instead of the 3D `EngineCanvas` ([Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md)) whenever the game has no 3D scene: it loads no three.js and no WebGPU scene, so a 2D game never pays for one.
A 2D game is plain game state plus two functions: `onStep(dt)` moves things at a fixed 60 Hz, and `onRender(draw)` draws the frame. The canvas maps every finger into stage units, runs the loop on `requestAnimationFrame`, and replays your draw calls into one Skia picture on the UI thread. React never re-renders per frame. The [puzzle module](https://docs.moonspell.app/modules/puzzle.md) is built on this layer.
## Smallest working example
Put this in `src/game/index.tsx`. Tap anywhere: the ball jumps to your finger, pops with a tween, throws sparks and ticks the haptics.
```tsx
import React, { useMemo, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import {
GameCanvas2D,
TopBar,
createParticles2D,
createTweens,
haptics,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
import { nunitoBlack } from '@/engine/ui/fonts/nunitoBlack';
import { nunitoBold } from '@/engine/ui/fonts/nunitoBold';
const FONTS = { display: nunitoBlack, ui: nunitoBold };
const Game = ({ onExit }: GameScreenProps) => {
const tweens = useMemo(() => createTweens(), []);
const sparks = useMemo(() => createParticles2D(300), []);
const ball = useRef({ x: 180, y: 300, scale: 1 }).current;
return (
{
tweens.step(dt);
sparks.step(dt);
}}
onPointer={(e) => {
if (e.type !== 'tap') return;
ball.x = e.x;
ball.y = e.y;
tweens.add({ from: 1.4, to: 1, duration: 0.25, easing: 'backOut', target: ball, key: 'scale' });
sparks.burst({ x: e.x, y: e.y, count: 20, color: ['#FFD166', '#FFFFFF'] });
haptics.tap();
}}
onRender={(draw, ctx) => {
draw.label('Tap anywhere', ctx.viewport.width / 2, ctx.viewport.safe.y + 120, { size: 28, weight: 'display' });
draw.disc(ball.x, ball.y, 30 * ball.scale, { color: '#FF7AB6' });
draw.particles(sparks);
}}
/>
);
};
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#14102A' } });
const game: GameDefinition = { id: 'tap-demo', title: 'Tap Demo', description: 'Tap to move the ball.', Component: Game };
export default game;
```
To run it, follow the [quickstart](https://docs.moonspell.app/start/quickstart.md). For a complete game on the layer (a 30 second round, score, best score on a leaderboard, an autopilot, a perf scene), read `examples/tap-pop/index.tsx`; see [Examples](https://docs.moonspell.app/examples.md).
The pattern to keep:
- Game state lives in refs or plain objects, never in React state that changes every frame.
- All movement happens in `onStep`. `onRender` only draws.
- The HUD (top bar, buttons, result cards) is React views over the canvas, updated only when a value changes. See [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md).
## `GameCanvas2D` props
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| `onRender` | `(draw: Draw2D, ctx: Canvas2DContext) => void` | required | Called every displayed frame, after the steps. Draw the game here. |
| `onStep` | `(dt: number, ctx: Canvas2DContext) => void` | none | Fixed-step simulation. `dt` is the step length (1/60 s by default). Step tweens, particles, cameras and bodies here. |
| `onPointer` | `(event: PointerEvent, ctx: Canvas2DContext) => void` | none | Taps, drags, swipes and long-presses, in stage units. |
| `stage` | `StageOptions` | `{ width: 360, fit: 'width' }` | The logical stage. See [Stage units](https://docs.moonspell.app/engine/2d.md#stage-units). |
| `background` | `ColorLike` | none | Fills the whole canvas, letterbox bars included, before each frame. |
| `fonts` | `{ display?, ui? }` | system faces | The game's font pair from `@/engine/ui/fonts` (anything with a `source`). Text drawn with `weight: 'display'` or `'ui'` uses them. Until they load, the system heavy and bold weights stand in. |
| `sheets` | `Sheet[]` | none | Sprite sheets the game draws. Each loads in the background. |
| `pixelArt` | `boolean` | `false` | Pixel-art mode every frame: nearest-neighbour sprites and tiles, no soft edges on shapes, the camera and sprites snapped to whole device pixels. |
| `pointer` | `Partial` | `DEFAULT_POINTER` | Tap, long-press and swipe thresholds. |
| `paused` | `boolean` | `false` | Stops the simulation. Rendering continues. |
| `step` | `number` | `1/60` | Seconds per simulation step. A change applies from the next frame without restarting the loop. |
| `onReady` | `(ctx: Canvas2DContext) => void` | none | Called once, when the canvas knows its size. |
| `onStats` | `(stats: Canvas2DStats, ctx: Canvas2DContext) => void` | none | About four times a second, for dev telemetry. |
| `testID` | `string` | `'game-canvas-2d'` | Test id of the canvas view. |
| `style` | `StyleProp` | `{ flex: 1 }` | Extra style for the canvas view. |
The loop stops when the canvas unmounts, so closing the game stops it. Inside the Moonspell app, a game kept off screen in the feed stops stepping and redraws twice a second; outside the app the canvas always runs.
### `Canvas2DContext`
Every callback gets the same `ctx` object.
| Field | What |
| --- | --- |
| `viewport` | The current `Viewport` (below). |
| `time` | Simulation time in seconds (the sum of steps). |
| `frame` | Frames drawn so far. |
| `measure(text, size, weight)` | Text width in stage units with the canvas's real fonts. |
| `stats` | The live `Canvas2DStats`. |
| `pixelRatio` | Device pixels per point, for `pixelZoom`. |
### `Canvas2DStats`
| Field | What |
| --- | --- |
| `fps` | Displayed frames per second, smoothed. |
| `steps` | Simulation steps since start. |
| `frameMs` | JS time per frame spent in steps and render, in ms, smoothed. |
| `ops` | Draw ops in the last frame. |
| `touches` | Fingers down. |
## Stage units
Lay everything out in stage units, never pixels. The viewport maps the stage onto the screen and maps touches back.
- `fit: 'width'` (the default): the stage is `width` units wide (360) and as tall as the screen's aspect makes it. Nothing is letterboxed. Read `ctx.viewport.height` to lay things out vertically.
- `fit: 'contain'`: a fixed `width` by `height` stage (default 360 by 640), scaled to fit and centred, with bars on the sides that do not match the aspect.
```tsx
import { GameCanvas2D } from '@/engine';
```
`StageOptions`:
| Field | Default | What |
| --- | --- | --- |
| `width` | `360` (`DEFAULT_STAGE_WIDTH`) | Logical width in stage units. |
| `height` | `640` (`DEFAULT_STAGE_HEIGHT`) | Logical height. Used only by `contain`. |
| `fit` | `'width'` | `'width'` or `'contain'`. |
`Viewport` fields:
| Field | What |
| --- | --- |
| `width`, `height` | Stage size in stage units. |
| `scale` | Points per stage unit. |
| `offsetX`, `offsetY` | Where stage (0, 0) sits on the canvas, in points. |
| `pixelWidth`, `pixelHeight` | Canvas size in points. |
| `fit` | The fit in use. |
| `safe` | The part of the stage clear of the notch and the home indicator, as a `Rect` in stage units. Keep controls inside it. The insets come from `useSafeInsets()`: the device's on phones (a notched iPhone's, top 47 and bottom 34, when none were measured), `env(safe-area-inset-*)` or 0 in a browser. |
| `visible` | All of the canvas in stage units (wider than the stage when letterboxed). |
| `toStage(px, py)` | Canvas point to stage point. |
| `toPixels(sx, sy)` | Stage point to canvas point. |
Helpers: `createViewport({ stage, pixels, insets })` builds one by hand (tests, custom hosts), `pointInRect(x, y, rect)` tests a point, `insetRect(rect, d)` shrinks a rect by `d` on every side (grows it when negative). A `Rect` is `{ x, y, w, h }` with y down.
## Pointer events
One gesture tracks every finger. Every event has `id` (the finger), `x`, `y`, `startX`, `startY` (where the finger went down) and `t` (ms), all in stage units.
| `type` | Extra fields | When |
| --- | --- | --- |
| `down` | | A finger touches. |
| `move` | `dx`, `dy` | A finger moves. |
| `up` | | A finger lifts. |
| `cancel` | | The system took the touches away (an incoming call). |
| `tap` | | Lifted without leaving `slop`, before `longPressMs`. |
| `longPress` | | Held still for `longPressMs`. Fires while the finger is still down. |
| `dragStart` | | Moved past `slop`. |
| `drag` | `dx`, `dy`, `vx`, `vy` | Every move while dragging. Velocity is in units per second. |
| `dragEnd` | `vx`, `vy` | The dragging finger lifts. Velocity is 0 if the finger stopped before lifting. |
| `swipe` | `direction`, `distance`, `speed` | A drag of at least `swipeMinDistance` at `swipeMinVelocity`. On release, or mid-gesture with `swipeEarly`. `direction` is `'left'`, `'right'`, `'up'` or `'down'`. |
React on `down` for instant feel (pick up a piece, pop a dot), on `tap` for buttons and board taps, on `swipe` for four-way games, and on the `drag` events for aiming and dragging.
`PointerThresholds` (pass any of them in the `pointer` prop):
| Field | Default | What |
| --- | --- | --- |
| `slop` | `8` | Movement in stage units below which a finger is still a tap. |
| `longPressMs` | `400` | Held this long without moving is a long-press. |
| `tapMaxMs` | `400` | Taps longer than this are dropped. |
| `swipeMinDistance` | `24` | Minimum travel for a swipe, stage units. |
| `swipeMinVelocity` | `300` | Minimum speed for a swipe, stage units per second. |
| `swipeEarly` | `false` | Fire the swipe as soon as it qualifies, before the finger lifts (snappier merge games). |
`createPointerClassifier(thresholds)` is the pure classifier the canvas uses (`down`, `move`, `up`, `cancel`, `tick`, `active`). Use it in tests.
For a stick and buttons over a 2D canvas, use `CharacterControls` from [Input](https://docs.moonspell.app/engine/input.md); the platformer and top-down controllers below read its input.
## The loop
The canvas runs `createLoop` for you: whole steps of `step` seconds from an accumulator, so the game behaves the same at 60 Hz and 120 Hz. After a stall (the app was in the background) at most 4 steps run and the rest of the time is dropped, so nothing jumps through a wall. Pausing does not replay the paused time.
`createLoop(options)` is exported for tests and custom hosts:
| Option | Default | What |
| --- | --- | --- |
| `step` | `1/60` | Seconds per step. |
| `maxCatchUp` | `4` | Most steps per tick before time is dropped. |
| `onStep(dt, time)` | | Each step. |
| `onRender(alpha, time)` | | After the steps of a tick. `alpha` is the leftover fraction of a step. |
The returned `Loop` has `tick(nowMs)` (returns the steps run), `start()`, `stop()`, `pause()`, `resume()`, `setStep(seconds)` (a new step size from the next tick, keeping time and steps; 0 or less means 1/60), and read-only `running`, `paused`, `time`, `steps`, `dropped` and `step`.
## Drawing
`onRender` gets a `Draw2D`. Every call takes stage units and appends numbers to a buffer; nothing touches Skia or React until the frame is handed over. Colours are `ColorLike`: a CSS string (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, or `transparent`, `black`, `white`, `red`, `green`, `blue`, `yellow`, `gray`) or an ARGB number (`0xAARRGGBB`). An unknown string draws magenta so you notice it.
### Shapes and text
| Call | Draws |
| --- | --- |
| `clear(color)` | The whole canvas, letterbox bars included. |
| `rect(x, y, w, h, style)` | A rect, rounded with `radius`. `RectStyle`: `color`, `stroke`, `strokeWidth` (default 2 when `stroke` is set), `alpha`, `radius`. Omit `color` for an outline only. |
| `tile(x, y, w, h, style)` | A game tile: a rounded rect with an optional drop shadow, gloss band and centred glyph, scaled around its centre. See `TileStyle` below. |
| `disc(cx, cy, r, style)` | A filled circle. `color` defaults to white; `stroke`, `strokeWidth`, `alpha`. |
| `ring(cx, cy, r, width, style)` | A circle outline `width` units thick. `color` (default white), `alpha`. |
| `line(x1, y1, x2, y2, style)` | A line. `width` (default 2), `color` (default white), `alpha`, `round` (round caps, default true). |
| `poly(points, style)` | A polygon from a flat `[x0, y0, x1, y1, ...]` array. `closed: false` draws an open polyline. Same fill and stroke fields as `rect`. |
| `label(text, x, y, style)` | Text, centred and middle-aligned by default. See `LabelStyle` below. |
| `textWidth(text, size?, weight?)` | Text width in stage units (size default 18, weight default `'bold'`). |
`TileStyle` (extends `RectStyle`):
| Field | Default | What |
| --- | --- | --- |
| `color` | white | Tile colour. |
| `radius` | 18% of the shorter side | Corner radius. |
| `shadow` | none | `true` for a flat shadow in a darker colour under the tile, or `{ dy, color }`. |
| `highlight` | `false` | A lighter band on the top part, for a glossy look. |
| `scale` | `1` | Scale around the centre (pop and press effects). |
| `glyph` | none | A short label in the middle (a number, a letter). |
| `glyphColor` | `'#FFFFFF'` | Glyph colour. |
| `glyphSize` | `0.5` | Glyph size as a fraction of the tile's height. |
| `glyphWeight` | `'display'` | Glyph font weight. |
`LabelStyle`:
| Field | Default | What |
| --- | --- | --- |
| `size` | `18` | Font size, stage units. |
| `weight` | `'bold'` | `'regular'`, `'bold'`, `'heavy'` (system faces) or `'display'`, `'ui'` (the game's `fonts`). |
| `color` | `'#FFFFFF'` | Text colour. |
| `align` | `'center'` | `'left'`, `'center'` or `'right'`. |
| `baseline` | `'middle'` | `'alphabetic'`, `'middle'`, `'top'` or `'bottom'`. |
| `alpha` | `1` | Opacity. |
### Composition
| Call | What |
| --- | --- |
| `alpha(k, fn)` | Multiplies every colour drawn inside `fn` by `k` (0 to 1). Nests. |
| `group({ x, y, scale, rotate, alpha }, fn)` | Draws `fn` moved, scaled and rotated (degrees) around (x, y). |
| `camera(cam, fn)` | Draws `fn` through a `Camera2D` (world units, shake included). |
| `clip(rect)` | Clips drawing to a rect until the matching `restore()`. |
| `save()`, `restore()`, `translate(x, y)`, `scale(sx, sy?)`, `rotate(degrees)` | Raw transform stack. |
| `particles(pool)` | Every live particle of a `Particles2D` as a disc. |
| `sprite(...)`, `sprites(...)` | Sprite sheets, below. |
| `setPixelArt(on)` | Pixel-art mode for the rest of the frame. The `pixelArt` prop calls it every frame. |
`Draw2D` also has `count` (ops this frame), `viewport` (the frame's viewport) and, for tests, `ops()` and `snapshot()`. `approxMeasure` is the rough text measure used before the canvas mounts.
## Tweens
A tween moves a number from `from` to `to` over `duration` seconds with an easing. Create one set per game and step it in `onStep`.
```tsx
import { createTweens } from '@/engine';
const tweens = createTweens();
const dot = { scale: 0, alpha: 1 };
// Grow in, then fade out and run a callback.
tweens.add({ from: 0, to: 1, duration: 0.3, easing: 'cubicOut', target: dot, key: 'scale' }).then({
from: 1,
to: 0,
duration: 0.12,
target: dot,
key: 'alpha',
onDone: () => console.log('gone'),
});
// In onStep:
tweens.step(dt);
```
`TweenSpec`:
| Field | Default | What |
| --- | --- | --- |
| `from`, `to` | required | Start and end values. |
| `duration` | required | Seconds. `0` jumps to `to` on the next step. |
| `delay` | `0` | Seconds before it starts. |
| `easing` | `'quadOut'` | An easing name or your own `(t) => number`. |
| `target`, `key` | none | Writes the value into `target[key]` every step. |
| `onUpdate(value)` | none | Called with the value every step. |
| `onDone()` | none | Called once at the end. |
`Tweens` methods: `add(spec)` returns a `TweenHandle`; `to(target, key, to, duration, options?)` tweens from the current value; `step(dt)`; `cancel(id)`; `cancelTarget(target)` cancels every tween writing into that object; `clear()`; `count` (running or waiting). A `TweenHandle` has `id`, `then(spec)` (a chain that starts the same step this one ends, with the leftover time), `cancel()` (cancels the chain) and `done`.
Easings (`EASINGS`): `linear`, `quadIn`, `quadOut`, `quadInOut`, `cubicIn`, `cubicOut`, `cubicInOut`, `sineInOut`, `backIn`, `backOut`, `elasticOut`, `bounceOut`.
## Particles
A fixed pool in typed arrays: no allocation per particle. `createParticles2D(max = 400, seed = 1)` returns a pool with `burst(options)`, `step(dt)`, `clear()`, `forEach(fn)`, `max` and `live`. Draw it with `draw.particles(pool)`. When the pool is full, new particles overwrite old ones.
```tsx
import { createParticles2D } from '@/engine';
const particles = createParticles2D(600);
particles.burst({ x: dot.x, y: dot.y, count: 22, color: [dot.color, '#FFFFFF'], size: [dot.r * 0.22, 0], speed: [120, 320], gravity: 500 });
```
`BurstOptions`:
| Field | Default | What |
| --- | --- | --- |
| `x`, `y` | required | Where the burst starts. |
| `count` | `16` | Particles to emit. |
| `angle` | `-90` | Direction in degrees (0 right, 90 down, -90 up). |
| `spread` | `360` | Spread around `angle` in degrees. 360 is every way. |
| `speed` | `[80, 220]` | Speed range, units per second. |
| `gravity` | `420` | Downward acceleration, units per second squared. |
| `drag` | `1.2` | Velocity lost per second, as a fraction. |
| `life` | `[0.35, 0.7]` | Life range, seconds. |
| `size` | `[5, 0]` | Radius at birth and at death. |
| `color` | white | Birth colour, or a list to pick one per particle. |
| `colorEnd` | the birth colour, transparent | Colour each particle fades towards. |
For GPU particles in a 3D scene, see [GPU effects](https://docs.moonspell.app/engine/gpu-effects.md).
## Sprite sheets
A sheet is one PNG holding many frames, as a grid (read left to right, top to bottom) or as named rects. Create it once at module level, pass it to the canvas, and draw frames by index or name.
```tsx
import { createSheet, GameCanvas2D, type Draw2D } from '@/engine';
// Your PNG under assets/: a 4 by 2 grid of frames.
const TILES = createSheet(require('../../assets/sprites/tiles.png'), { cols: 4, rows: 2 });
// Named frames: createSheet(require(...), { frames: { coin: { x: 0, y: 0, w: 64, h: 64 } } })
const render = (draw: Draw2D) => {
if (TILES.ready) draw.sprite(TILES, 3, 40, 40, 48, 48);
else draw.tile(40, 40, 48, 48, { color: '#FFB454' }); // the shape until the art loads
};
//
```
A sheet that has not loaded draws nothing, so keep a vector fallback. That way art can arrive later without code changes. A 1 by 1 image counts as a placeholder: it loads, but `sheet.ready` stays false and `sheet.placeholder` is true, so the fallback keeps drawing.
`Sheet` fields: `id`, `source`, `layout`, `width`, `height` (pixels, once loaded), `ready`, `placeholder`, `count` (frames) and `frame(f)` (the source rect, or null when unknown or not loaded). `SheetLayout` is `{ cols, rows }` or `{ frames }`. `gridFrameRect(cols, rows, width, height, index)` and `markSheetLoaded(sheet, width, height)` are the pure helpers behind it (use `markSheetLoaded` in tests).
### `draw.sprite` and `draw.sprites`
`draw.sprite(sheet, frame, x, y, w, h, style)` draws one frame into a rect. `SpriteStyle`:
| Field | Default | What |
| --- | --- | --- |
| `alpha` | `1` | Opacity. |
| `rotate` | `0` | Degrees, around the rect's centre. |
| `flipX`, `flipY` | `false` | Mirror left-right (a character facing left) or upside down. |
| `tint` | none | Multiplies the sprite's colours. White leaves it as drawn; a blue gives a frozen version. |
| `flash` | none | Paints this colour over the sprite's shape (a white hit flash). |
| `flashAmount` | `1` | 0 to 1 of the flash colour. |
`draw.sprites(sheet, list, style)` draws many frames of one sheet in a single draw call (tiles, coins, particles with art). Each `AtlasSprite` is `{ frame, x, y, size, rotate?, tint? }`, where (x, y) is the centre and `size` is the drawn width (height follows the frame's aspect). The batch `style` (`BatchStyle`) takes `alpha`, `tint`, `flash` and `flashAmount`.
A hit flash from Sky Runner: set `w.flash = 1` on a hit, fade it in `onStep`, and draw:
```tsx
// RUNNER is a createSheet(...) of the runner's poses; anim is a sprite animator (below).
draw.sprite(RUNNER, anim.frame, x, y, 35, 35, {
flipX: p.facing < 0,
flash: w.flash > 0 ? '#FFFFFF' : undefined,
flashAmount: w.flash,
});
```
### Animated sprites
`createSpriteAnimator(clips, initial?)` plays named clips of frames. Each `SpriteClip` is `{ frames, fps, loop? }` (`loop` defaults to true; false holds the last frame). The animator has `frame` (to draw now), `clip`, `done` (a non-looping clip finished), `play(name, restart?)` (no-op if already playing, unless `restart`) and `update(dt)`.
```tsx
import { createSpriteAnimator } from '@/engine';
const anim = createSpriteAnimator({ idle: { frames: [0, 1], fps: 3 }, run: { frames: [2, 3, 4, 5], fps: 12 } }, 'idle');
// In onStep:
anim.play(Math.abs(p.vx) > 20 ? 'run' : 'idle');
anim.update(dt);
// In onRender:
draw.sprite(RUNNER, anim.frame, p.x, p.y, 32, 32);
```
### Pixel art
`` keeps pixel art crisp: nearest-neighbour sampling, no antialiasing on shapes (no seams between tiles), and the camera offset and sprite rects snapped to whole device pixels so nothing shimmers while scrolling. For every art pixel to be the same size on screen, also give the camera a whole-pixel zoom with `pixelZoom(viewport, zoom, artPixel = 1, pixelRatio = 1)`. `artPixel` is world units per art pixel: 1 for 32-pixel tiles drawn 32 units wide, 2 for 16-pixel tiles drawn 32 units wide.
```tsx
import { GameCanvas2D, pixelZoom } from '@/engine';
(camera.zoom = pixelZoom(ctx.viewport, 1, 1, ctx.pixelRatio))} onStep={onStep} onRender={onRender} />
```
## Camera
`createCamera2D(options)` gives a camera in stage units: the world point at the centre of the view, a zoom, smooth follow, screen shake and optional bounds the view never leaves. Step it in `onStep` and draw world content inside `draw.camera(cam, fn)`. Draw the HUD-like parts outside it.
```tsx
import { cameraView, createCamera2D, drawTileMap } from '@/engine';
const camera = createCamera2D({ viewWidth: 360, viewHeight: 640, bounds: { x: 0, y: 0, w: MAP.width, h: MAP.origin.y + MAP.height } });
// In onStep:
camera.follow(p.x + p.w / 2 + 40, p.y + p.h / 2 - 60, 0.12);
camera.step(dt);
// In onRender:
draw.camera(camera, () => drawTileMap(draw, MAP, { view: cameraView(camera) }));
```
`Camera2DOptions`:
| Field | Default | What |
| --- | --- | --- |
| `viewWidth`, `viewHeight` | required | View size in stage units (the stage's width and height). |
| `x`, `y` | the view's centre | Starting world point at the centre. |
| `zoom` | `1` | Zoom (at least 0.05). |
| `bounds` | `null` | A world `Rect` the view stays inside. |
| `seed` | `7` | Seed for the shake. |
`Camera2D` methods: `follow(tx, ty, lerp = 0.12)` (eases towards the point; `lerp` is the fraction of the gap closed per 1/60 s, so it is frame-rate independent), `lookAt(tx, ty)` (jumps there and stops following), `shake(amplitude, duration)`, `step(dt)`, `transform()` and `toWorld(sx, sy)` (a stage point, such as a touch, to a world point). Fields `x`, `y`, `zoom`, `viewWidth`, `viewHeight` and `bounds` are writable; `shakeX` and `shakeY` are read-only.
`cameraView(cam)` returns the part of the world the camera sees, for culling. `drawParallax(draw, cam, factor, fn, { repeatWidth })` draws a background layer that scrolls at `factor` of the camera (0 fixed to the screen, 0.3 far hills, 1 the world). With `repeatWidth`, `fn(offsetX)` is called at every offset needed to cover the view, for a strip that repeats without gaps. Call `drawParallax` outside `draw.camera`.
```tsx
import { drawParallax } from '@/engine';
drawParallax(draw, camera, 0.2, (ox) => {
for (let i = 0; i < 4; i++) draw.disc(ox + 60 + i * 110, 360, 90 + (i % 2) * 30, { color: '#2A2160' });
}, { repeatWidth: 440 });
```
## Tile maps
A level is rows of characters, one tile per character, in stage units with y down.
```tsx
import { createTileMap } from '@/engine';
const MAP = createTileMap(
[
'..........o.o........',
'.........=====.....G.',
'..S.................#',
'#######...####^^#####',
],
{ tileSize: 32, legend: { '#': { kind: 'solid', color: '#3B3F6B' }, o: { kind: 'empty' }, S: { kind: 'empty' }, G: { kind: 'empty' } } },
);
const WORLD = { tiles: MAP };
const start = MAP.find('S')[0]; // { col, row }
```
The default legend (`DEFAULT_TILES`): `.` and space are `empty`, `#` is `solid`, `=` is `oneway` (you jump up through it and land on it), `^` is `hazard` (spikes, lava). Your `legend` is merged over it. A `TileDef` is `{ kind, frame?, color? }`: `frame` is the cell in a tileset sheet, `color` a flat colour drawn when there is no sheet. Characters not in the legend are empty, which makes them good markers (start, goal, coins) that you read with `find`.
`TileMapOptions`: `tileSize` (default 32), `origin` (top-left on the stage, default `{ x: 0, y: 0 }`), `legend`.
`TileMap` has `cols`, `rows`, `tileSize`, `origin`, `width`, `height`, and the methods `tile(col, row)`, `kindAt(col, row)`, `cellAt(x, y)`, `rectOf(col, row)`, `overlapping(rect)` (every non-empty cell in a rect), `set(col, row, ch)` (a block breaks, a door opens) and `find(ch)`. Cells outside the map are empty.
`drawTileMap(draw, map, { sheet, view, alpha, tint, flash, flashAmount })` draws the tiles inside `view` (default the whole map). With a loaded sheet, tiles with a `frame` go out in one atlas draw; otherwise flat colours are merged into runs along each row. Call it inside `draw.camera`.
## Bodies, platformer and top-down
These are boxes that collide with a tile map and extra rects. They are not a physics engine; for rigid bodies in 3D see [Physics](https://docs.moonspell.app/engine/physics.md).
### Bodies
`createBody2D(x, y, w, h)` returns a `Body2D`: top-left `x`, `y`, size `w`, `h`, velocity `vx`, `vy`, and the contact flags `onGround`, `onWall` (-1 left, 1 right, 0 none), `onCeiling` and `hurt` (touched a hazard tile this step).
`moveBody(body, dx, dy, world)` moves a body x first, then y, so it slides along walls and lands on floors. Big moves are split so a fast body cannot pass through a thin wall. One-way tiles and rects only stop a body falling onto them from above. It sets the contact flags and zeroes the velocity into what it hit.
A `World2D` is `{ tiles?, solids?, oneWays? }`: a tile map, extra solid rects (crates, moving platforms) and extra one-way rects.
`raycast2D(world, x1, y1, x2, y2, oneWays = false)` returns the first solid a segment hits as `{ x, y, t, nx, ny }` (point, fraction along the segment, face normal), or null. Use it for line of sight, lasers, and enemies that turn at walls and ledges.
`Triggers2D` handles coins, keys, exits and checkpoints: `add(rect, { data, once, onEnter, onExit })`, then `update([{ id, body }])` each step. `onEnter(id, data)` fires once per body entering; a `once` trigger is removed after its first enter. `size` is the number of triggers left.
```tsx
import { Triggers2D } from '@/engine';
const triggers = new Triggers2D();
coins.forEach((c, i) => triggers.add({ x: c.x - 10, y: c.y - 10, w: 20, h: 20 }, { data: i, once: true, onEnter: () => (coins[i].taken = true) }));
// In onStep:
triggers.update([{ id: 'player', body: player }]);
```
### Platformer controller
`createPlatformer(x, y, w = 24, h = 32)` returns a `PlatformerState` (a `Body2D` plus `facing`, and `jumped` and `landed`, true on the step they happen). `stepPlatformer(state, input, dt, world, params = DEFAULT_PLATFORMER)` runs it: acceleration and braking, air control, coyote time, a jump buffer, short hops when jump is released early, wall slide and wall jump.
The input is `{ x, jump, jumpPressed }` (`PlatformerInput`). `platformerInput(input, jumpButton = 'jump')` builds it from the `CharacterInput` that `CharacterControls` writes, and clears the jump press it read. This is the core of `examples/sky-runner/index.tsx`:
```tsx
import React, { useMemo } from 'react';
import { View } from 'react-native';
import {
CharacterControls,
GameCanvas2D,
createCharacterInput,
createPlatformer,
createTileMap,
drawTileMap,
platformerInput,
stepPlatformer,
type Draw2D,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
const MAP = createTileMap(['..........', '..........', '####..####'], { tileSize: 32, origin: { x: 0, y: 400 } });
const WORLD = { tiles: MAP };
const Runner = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const player = useMemo(() => createPlatformer(40, 300, 22, 30), []);
const onStep = (dt: number) => {
const cmd = platformerInput(input);
for (const k of Object.keys(input.pressed)) delete input.pressed[k]; // rising edges are read once
stepPlatformer(player, cmd, dt, WORLD);
if (player.hurt || player.y > 800) Object.assign(player, createPlatformer(40, 300, 22, 30));
};
const onRender = (draw: Draw2D) => {
drawTileMap(draw, MAP);
draw.rect(player.x, player.y, player.w, player.h, { color: '#7C5CFF', radius: 6 });
};
return (
);
};
const game: GameDefinition = { id: 'runner', title: 'Runner', description: 'Run and jump.', Component: Runner };
export default game;
```
`DEFAULT_PLATFORMER` (`PlatformerParams`; pass your own for floatier or snappier games):
| Field | Default | What |
| --- | --- | --- |
| `runSpeed` | `220` | Top run speed, units per second. |
| `accel` | `1800` | Ground acceleration. |
| `decel` | `2400` | Ground braking. |
| `airAccel` | `1300` | Steering in the air. |
| `gravity` | `1900` | Units per second squared. |
| `maxFall` | `900` | Top fall speed. |
| `jumpHeight` | `110` | Jump apex height in units. Take-off speed follows from gravity. |
| `jumpCut` | `0.45` | Share of upward speed kept when jump is released while rising (short hops). |
| `coyoteTime` | `0.1` | Seconds after leaving a ledge that a jump still works. |
| `jumpBuffer` | `0.12` | Seconds a jump press is remembered before landing. |
| `wallSlideSpeed` | `110` | Slide speed down a wall you push into. 0 turns it off. |
| `wallJumpPush` | `260` | Sideways push of a wall jump. 0 turns wall jumps off. |
With the defaults, keep gaps a player must jump at most 4 tiles wide.
### Top-down controller
`createTopDown(x, y, w = 24, h = 24)` returns a `TopDownState` (a `Body2D` plus `facing`, an angle in radians with 0 right and y down). `stepTopDown(state, stick, dt, world, params = DEFAULT_TOP_DOWN)` moves it 8 ways with acceleration and friction; `stick` is `{ x, y }` with y down, clamped to the unit disc. `topDownInput(input)` builds the stick from `CharacterControls` input (it flips the stick's up to the stage's down).
`DEFAULT_TOP_DOWN`: `speed` 160, `accel` 1400, `friction` 1800.
## Grid
`createGrid({ rows, cols, rect, gap = 0, cellSize? })` lays square cells inside a rect, centred, with `gap` between them: the math every board game shares.
```tsx
import { createGrid, insetRect } from '@/engine';
const grid = createGrid({ rows: 8, cols: 8, rect: insetRect(ctx.viewport.safe, 16), gap: 4 });
const cell = grid.cellAt(e.x, e.y); // null outside the grid or in a gap
```
A `Grid` has `rows`, `cols`, `gap`, `cell` (side of one cell), `bounds`, and `cellAt(x, y)`, `nearestCell(x, y)` (clamped, for snapping drags), `rectOf(cell)`, `centerOf(cell)`, `inBounds(cell)`, `neighbors(cell, { diagonal })`, `cells()` (row by row), `index(cell)` and `cellOf(index)`. A `Cell` is `{ row, col }`.
## Shape tests
Pure functions for arcade games, on `Rect` (`{ x, y, w, h }`) and `Circle` (`{ x, y, r }`):
| Function | Returns |
| --- | --- |
| `aabbOverlaps(a, b)` | Whether two rects overlap. |
| `aabbContains(outer, inner)` | Whether `outer` fully contains a rect or a point. |
| `circleOverlaps(a, b)` | Whether two circles overlap. |
| `circleRectOverlaps(c, r)` | Whether a circle and a rect overlap. |
| `resolveAabb(moving, fixed)` | `{ dx, dy }` to push `moving` out along the axis of least penetration; zeros when apart. |
| `segmentHitsRect(x1, y1, x2, y2, r)` | `{ t, x, y }` where the segment enters the rect, or null. A start inside hits at `t = 0`. |
## Colours
`parseColor(string)` returns ARGB (cached). `toArgb(colorLike)` accepts a string or a number. `argb(a, r, g, b)` builds one. `withAlpha(argb, k)`, `mixColor(a, b, t)` and `shade(argb, k)` (lighter for k above 0, darker below) work on ARGB numbers, so convert strings with `toArgb` first.
## Haptics and sounds
`haptics` has `tap()` (a piece snaps in), `select()` (a selection change), `success()` (a line cleared), `warning()` (an illegal move), `error()` (game over) and `heavy()` (a big hit). Calls are rate-limited (35 ms apart), do nothing where haptics do not exist, and follow the player's `haptics` setting (`setSettings({ haptics: false })`, see [Saves](https://docs.moonspell.app/engine/saves.md)). `haptics.enabled = false` mutes them in code.
`useOneShots(sources, options)` loads short sound effects by name and releases them on unmount, so nothing keeps sounding after the game exits:
```tsx
import { useOneShots } from '@/engine';
const SFX = { pop: require('../../assets/sfx/pop.wav') };
// Inside the component:
const sfx = useOneShots(SFX);
sfx.play('pop', { volume: 0.8, rate: 1.1 });
```
| Option | Default | What |
| --- | --- | --- |
| `voices` | `3` (1 to 6) | Players per sound, so overlapping plays do not cut each other off. |
| `volume` | `1` | Master volume 0 to 1. |
`play(name, { volume, rate })` (`rate` clamped to 0.5 to 2). `muted`, `counts` (plays per sound, for telemetry) and `dispose()` are on the returned object. Sounds mix with other audio and follow the `sound` and `volume` settings. `createOneShots` is the same thing outside a component. For music, buses and positional sound, see [Audio](https://docs.moonspell.app/engine/audio.md).
## Gotchas
- **Never allocate per frame in `onRender`.** Keep state in refs or plain objects and update React state only on change (Tap Pop emits a snapshot every sixth frame and skips equal ones).
- **Move things only in `onStep`.** `onRender` runs at the display rate, `onStep` at a fixed rate; movement in `onRender` runs faster on 120 Hz phones.
- **Props are read through refs.** Changing `onStep`, `onRender`, `paused` or `step` takes effect next frame; the loop keeps running (simulation time and step count carry on).
- **Sheets draw nothing until loaded.** Always check `sheet.ready` and draw a shape otherwise.
- **`createSheet` belongs at module level**, not inside a render, so the sheet keeps its id and its loaded image.
- **Text uses the system faces until `fonts` load.** Measure with `ctx.measure` or `draw.textWidth` each frame instead of caching widths from the first frame.
- **No emoji in canvas text.** Draw icons with `poly` and `disc`, use sprites, or put an `Icon` in the React HUD ([HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)).
- **The canvas has no accessibility elements.** Automated tests cannot find what you draw; give React controls a `testID` and, in dev builds, print the pixel position of things a test must tap with `viewport.toPixels`.
- **Rising edges from `CharacterControls`.** `platformerInput` reads the jump press once and clears `input.pressed[jumpButton]`, like `consumeInput` from [Input](https://docs.moonspell.app/engine/input.md), so call it once per step. It leaves other buttons' presses alone: read and clear those yourself (as above).
- **Performance budget.** The layer is measured at 64 tiles plus a 200-particle burst well under 8 ms of JS per frame (Tap Pop's dev perf button shows it).
## See also
- [Examples](https://docs.moonspell.app/examples.md): `tap-pop` (canvas, taps, loop, tweens, particles, haptics, a one-shot sound, a best score) and `sky-runner` (tile map, platformer controller, one-way ledges, spikes, coins as triggers, camera follow, parallax, pixel-art sheets, an autopilot built on `raycast2D`).
- [Puzzle module](https://docs.moonspell.app/modules/puzzle.md): block, merge, match and sort games on this layer.
- [Input](https://docs.moonspell.app/engine/input.md): `CharacterControls`, `Joystick`, keyboard on web.
- [Game feel](https://docs.moonspell.app/engine/game-feel.md), [Audio](https://docs.moonspell.app/engine/audio.md), [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md).
- [Net](https://docs.moonspell.app/engine/net.md) for the leaderboard Tap Pop uses, and [Saves](https://docs.moonspell.app/engine/saves.md) for coins, unlocks and settings.
---
# Character and cameras
Source: https://docs.moonspell.app/engine/character-and-cameras
> A body that walks, runs, jumps, hangs from ledges and looks around in a level of boxes, four camera rigs and the multi-touch controls that drive it.
The character foundation is everything a 3D game needs for a person or creature that moves through a space: a **level** of boxes to collide with, a **character controller** driven by a command object, four **camera** rigs, and **touch controls** that write that command. Use it for parkour, arenas, tag, stealth, horror, top-down and side-view games, and as the base for bots and combat. Racing games use the [racing module](https://docs.moonspell.app/modules/racing.md) instead.
Everything on this page is exported from `@/engine`. The code lives in `src/engine/level/`, `src/engine/character/` and `src/engine/input/` of the game template.
## Smallest working example
A course of platforms over a void, a capsule body, a third-person camera, a stick on the left half, a look pad on the right half and a JUMP button. Falling respawns you at the start.
```tsx
import React, { useMemo, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import {
CharacterCamera,
CharacterControls,
CharacterMesh,
EngineCanvas,
Environment,
LevelMeshes,
TopBar,
WORLD_FRAME,
consumeInput,
createCharacterInput,
createCharacterState,
createCommand,
moveFrameFor,
resolveCameraRig,
resolveCharacterParams,
resolveLevel,
respawnCharacter,
stepCharacter,
type CharacterHandle,
type CharacterInput,
type CharacterState,
type GameDefinition,
type GameScreenProps,
} from '@/engine';
const LEVEL = resolveLevel({ preset: 'course', seed: 11 });
const PARAMS = resolveCharacterParams({ runSpeed: 6.5, jumpHeight: 1.6 });
const RIG = resolveCameraRig('third');
interface SimProps {
input: CharacterInput;
body: { current: CharacterState };
}
/** Inside the 3D canvas: one body, the level and the camera. */
const Simulation = ({ input, body }: SimProps) => {
const mesh = useRef(null);
const cmd = useRef(createCommand());
const frame = useRef({ ...WORLD_FRAME });
useFrame((_, rawDt) => {
const dt = Math.min(rawDt, 1 / 30);
const s = body.current;
const f = moveFrameFor(RIG, s, frame.current); // what "forward" means for the stick
consumeInput(input, cmd.current); // thumbs (or keys) to a command
stepCharacter(s, cmd.current, dt, LEVEL, PARAMS, f);
if (s.y < LEVEL.killY) respawnCharacter(s, LEVEL.spawns[0]);
const h = mesh.current;
if (h?.group) {
h.group.position.set(s.x, s.y, s.z);
h.group.rotation.y = s.yaw;
}
});
return (
<>
body.current} />
body.current.speed / PARAMS.runSpeed}
/>
>
);
};
const Hop = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const body = useRef(createCharacterState(LEVEL.spawns[0]));
return (
);
};
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' } });
const game: GameDefinition = {
id: 'hop',
title: 'Hop',
description: 'Jump across the platforms to the green pad.',
Component: Hop,
};
export default game;
```
Put it in `src/game/index.tsx` and run the game as described in the [quickstart](https://docs.moonspell.app/start/quickstart.md). On a desktop browser, WASD or the arrow keys move, Space jumps and dragging the mouse on the right half turns the camera.
The loop is always the same four steps, once per frame:
1. `moveFrameFor(rig, state, frame)` says what "forward" means for the stick. The camera decides.
2. Something writes a `CharacterCommand`: `consumeInput` for the player's thumbs, a bot brain ([bots](https://docs.moonspell.app/engine/bots.md)), or the dev autopilot.
3. `stepCharacter(state, cmd, dt, level, params, frame)` moves the body and collides it with the level.
4. You copy the state onto the mesh. `CharacterCamera` follows on its own.
The full version of this game, with checkpoints, a goal, a moving platform, a camera button, a best time and an autopilot, is the **Rooftop Hop** example (`examples/rooftop-hop/index.tsx`). **Lantern Tag** adds bots and a town laid out with `gridMap`, and **Pulse Arena** adds weapons. See [Examples](https://docs.moonspell.app/examples.md).
## Units and conventions
- Units are metres and seconds. +Y is up.
- A body's position is its **feet**.
- Yaw 0 faces +Z. Yaw increases to the left. `forwardOf(yaw)` and `rightOf(yaw)` give the unit vectors on the ground plane, so at yaw 0 forward is +Z and right is -X.
- The stick is a unit disc: +x is right on screen, +y is up on screen (forward).
- All state lives in plain mutable objects and refs, never in React state. React renders once; `useFrame` does the rest.
## Levels
A level is a flat floor plus axis-aligned boxes. Boxes are the only collision shapes, which keeps collision, rays and bot pathfinding small and fast on a phone. Ramps, stairs, pillars and doors are pieces that resolve to boxes.
```ts
import { resolveLevel } from '@/engine';
const LEVEL = resolveLevel({
preset: 'course',
seed: 11,
theme: { sky: '#1B1440', platform: '#5B6BD6', block: '#E0A458', checkpoint: '#2EE6D6', goal: '#4ADE80' },
});
```
`resolveLevel(def)` fills every default and clamps every value, so any definition renders and is walkable. Resolve it once at module scope and pass the `Level` around.
### `LevelDef`
| Field | Type | Default | Notes |
| --- | --- | --- | --- |
| `preset` | `'arena'`, `'course'` or `'corridors'` | `'arena'` | When you pass `boxes` and no `preset`, the level is custom: only your boxes. |
| `boxes` | `LevelBox[]` | `[]` | Added to the preset's boxes, or the whole layout for a custom level. |
| `pieces` | `LevelPiece[]` | `[]` | Ramps, stairs, cylinders, arches, walls with doors. |
| `size` | number | 22 arena, 24 course, 18 corridors, 20 custom | Half-size of the playable square, clamped to 8 to 120 m. |
| `floorY` | number | 0 (course: -30) | A floor below -20 means no floor: a void. |
| `fence` | boolean | true (course: false) | Walls around the square. |
| `fenceHeight` | number | 3 | Clamped to 1 to 8 m. |
| `killY` | number | floor minus 6 (course and voids: -8) | The engine does not respawn for you: check `state.y < level.killY`. |
| `spawns` | `LevelPoint[]` | per preset, else one at the origin | |
| `checkpoints` | `LevelPoint[]` | per preset | Drawn as rings by `LevelMeshes`. |
| `goal` | `LevelPoint` or `null` | per preset (only `course` has one) | `null` removes a preset's goal. |
| `theme` | `LevelTheme` | see below | |
| `seed` | number | 7 | Seed for the presets' random placement. |
A `LevelPoint` is `{ x, y?, z, yaw? }`. `y` defaults to the floor (0 for a void) and `yaw` to 0.
A `LevelBox` is `{ x, y?, z, w, h, d, kind?, color? }`: `x` and `z` are the centre, `y` is the bottom face (default 0), `w`, `h`, `d` are the full sizes along x, y and z. `kind` is one of:
| `kind` | Meaning |
| --- | --- |
| `'wall'` | Tall, blocks movement and sight. Never a ledge to grab. |
| `'platform'` | Something to stand on. |
| `'block'` | A crate-sized obstacle. The default. |
`LevelTheme` colours (any CSS colour) and their defaults: `floor` `#2B3144`, `wall` `#5A627A`, `platform` `#3E7CB1`, `block` `#C98A4B`, `checkpoint` `#2EE6D6`, `goal` `#4ADE80`, `sky` `#141A2E`, plus `fog` (default `true`). The engine does not apply `sky` or `fog` for you: pass `level.theme.sky` to `EngineCanvas` `background` and use `level.theme.fog` when you set up `Environment`, as Rooftop Hop does.
### Presets
| Preset | Layout | Good for |
| --- | --- | --- |
| `arena` | A fenced square with four pillars, a low central stage and scattered crates. Four spawns, one per side. | Tag, brawls, shooters. |
| `course` | Nine platforms over a void, rising and drifting sideways with 2.2 to 3 m gaps, a crate on every other one. Checkpoints on platforms 3 and 6, the goal on the last. | Parkour, obstacle courses. |
| `corridors` | A fenced small maze of long walls plus crates. Two spawns in opposite corners. | Stealth, horror, hide-and-seek. |
`LEVEL_PRESETS` holds a bare definition per preset, `PRESET_SIZE` the default sizes, and `buildPreset(preset, size, seed)` the raw layout.
### The resolved `Level`
| Field | Type | Notes |
| --- | --- | --- |
| `preset` | preset name or `'custom'` | |
| `size` | number | |
| `bounds` | `{ minX, maxX, minZ, maxZ }` | The square, grown to hold every box (a course runs long in +Z). |
| `floorY`, `hasFloor`, `killY` | number, boolean, number | |
| `boxes` | `ResolvedBox[]` | `minX`, `maxX`, `minY`, `maxY`, `minZ`, `maxZ`, `kind`, `color`, and the flags `moving`, `hidden`, `climb`. |
| `pieces` | `ResolvedPiece[]` | Ramp wedges and cylinders drawn over their hidden collision boxes. |
| `spawns`, `checkpoints` | `ResolvedPoint[]` | `{ x, y, z, yaw }` with every field filled. |
| `goal` | `ResolvedPoint` or `null` | |
| `theme` | `Required` | |
`ResolvedBox` flags: `hidden` boxes collide but `LevelMeshes` does not draw them (a ramp's steps, a cylinder's box, a model's bounds). `moving` boxes belong to a `MovingPlatform` and are drawn by it. `climb` marks ramp and stair steps that bots may climb between.
### Pieces
Pieces are shapes beyond plain boxes. Each one becomes boxes the controller, the bots and the rays already understand. For every piece, `x` and `z` are the centre, `y` the bottom (default 0) and `color` overrides the theme.
| `kind` | Fields | What it becomes |
| --- | --- | --- |
| `'ramp'` | `w`, `d`, `rise`, `facing` | Hidden steps 0.1 m tall (`RAMP_STEP`) under a smooth drawn wedge, so the body walks up without hopping. `d` runs along `facing`. Theme colour `platform`. |
| `'stairs'` | `w`, `d`, `rise`, `facing`, `steps?` | Visible steps. `steps` defaults to `rise / 0.25` (`STAIR_STEP`), rounded up. Theme colour `platform`. |
| `'cylinder'` | `radius`, `h` | A hidden `wall` box (0.85 of the radius each side) under a drawn cylinder. Theme colour `block`. |
| `'arch'` | `w`, `h`, `axis`, `post?`, `lintel?` | Two posts and a lintel around an opening `w` wide and `h` tall. `post` and `lintel` default to 0.5 m. Theme colour `wall`. |
| `'wall-door'` | `length`, `h`, `axis`, `thickness?`, `door?` | A wall with an opening. `thickness` defaults to 0.3 m; `door` is `{ width?, height?, offset? }`, default 1.2 by 2.2 m, centred. Theme colour `wall`. |
`facing` is `'x+'`, `'x-'`, `'z+'` or `'z-'`: the direction the ramp or stairs climbs toward. `axis` is `'x'` or `'z'`: the direction the wall line runs.
The controller walks up ramps and stairs, because each step is under its step height, and walks down them without hopping, because a body that was walking snaps down to the surface up to one step below.
### Grid maps
`gridMap(rows, options)` lays out boxes and pieces from a string map, one character per cell. Rows go +Z (row 0 is the north edge, at -Z) and columns go +X. Spread the result into `resolveLevel`. This is the town block in Lantern Tag:
```ts
import { gridMap, resolveLevel } from '@/engine';
/** A hut with a door, a raised yard reached by two ramps, and a pillar. */
const TOWN = gridMap(
[
'##D##>PP',
'#...#.PP',
'#...#.^.',
'#####..O',
],
{ cell: 1.2, origin: { x: 12, z: 16.6 }, wallHeight: 2.6, platformHeight: 1 },
);
const LEVEL = resolveLevel({ preset: 'arena', seed: 4, boxes: TOWN.boxes, pieces: TOWN.pieces });
```
| Character | Cell |
| --- | --- |
| `#` | Wall filling the cell, `wallHeight` tall. |
| `D` | Wall with a door through it, along the row. |
| `\|` | Wall with a door through it, along the column. |
| `B` | Crate, 0.8 m. |
| `P` | Platform, `platformHeight` tall. |
| `O` | Pillar (a cylinder), `wallHeight` tall. |
| `^`, `>`, `v`, `<` | Ramp up to the north, east, south, west, rising to `platformHeight`. |
| `N`, `E`, `S`, `W` | Stairs up to the north, east, south, west, rising to `platformHeight`. |
| `.` or space | Empty. |
| Option | Default | Notes |
| --- | --- | --- |
| `cell` | 2 | Metres per cell. |
| `origin` | centred on the origin | World `{ x, z }` of the map's north-west corner. |
| `y` | 0 | Bottom of every piece. |
| `wallHeight` | 3 | |
| `platformHeight` | 1 | |
| `legend` | `DEFAULT_LEGEND` | Adds or replaces characters. Each entry is a `GridCell`: a function that gets `{ x, z, cell, y, wallHeight, platformHeight }` for the cell centre and returns `{ boxes?, pieces? }`. |
An unknown character throws an error that names its row and column.
### Drawing the level
`` draws the floor, one mesh per visible box, the ramp wedges and cylinders, the checkpoint rings and the goal pad.
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `level` | `Level` | required | |
| `nextCheckpoint` | number | 0 | Rings with a lower index draw dim, as passed. |
| `showGoal` | boolean | true | |
For scenery beyond boxes, two procedural shapes are drawn only (no collision): add a box or a piece where they stand.
| Component | Props |
| --- | --- |
| `Extruded` | `outline` (`[x, z][]` footprint), `height`, `color` (`#C9CED8`), `position` (`[0, 0, 0]`), `rotationY` (0), `bevel` (0 m). A footprint pushed up: a house, a plinth, a fence. |
| `Lathed` | `profile` (`[radius, y][]` from bottom to top), `segments` (24), `color` (`#C9CED8`), `position` (`[0, 0, 0]`). A profile spun around the vertical axis: a vase, a tower, a lamp post. |
```tsx
import { Extruded, Lathed } from '@/engine';
const Props = () => (
<>
>
);
```
Generated `.glb` props can join the level as hidden boxes with ``. See [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md).
## Collision and rays
These are the functions the controller, cameras and bots use. Call them yourself for line of sight, pickups or custom bodies.
| Function | Returns | What it does |
| --- | --- | --- |
| `collideCapsule(level, body, prevY, capsule, out?, snapDown = 0)` | `CollisionResult` | Pushes a standing capsule (`{ radius, height, stepHeight }`) out of every box and the floor. Mutates `body` (feet position). `prevY` is the feet height before this frame's move: it decides landing on a box versus sliding along its side. `snapDown` keeps a walking body on the ground up to that far below. |
| `createCollisionResult()` | `CollisionResult` | A reusable result: `grounded`, `hitWall`, `hitCeiling`, `wallNormalX`, `wallNormalZ`, `standingOn`. |
| `raycastLevel(level, a, b, out?)` | `RayHit` or `null` | The first box or floor a segment from `a` to `b` touches. `RayHit` is `{ t, x, y, z, nx, ny, nz, box }`, with `t` from 0 to 1 along the segment and `box` null for the floor. Hidden boxes count. |
| `segmentClear(level, a, b)` | boolean | True when nothing blocks the segment. |
| `groundHeightAt(level, x, z, y, tolerance = 0.05)` | number or `null` | The highest surface under `(x, z)` at or below `y + tolerance`, or the floor; `null` over a void. |
| `capsuleOf(params)` | `Capsule` | The capsule a `CharacterParams` describes. |
For physics bodies that the character should shove (crates, barrels), see [Physics](https://docs.moonspell.app/engine/physics.md).
## The character controller
### `resolveCharacterParams(config)`
Fills defaults and clamps a `CharacterConfig` into `CharacterParams`. Resolve once, at module scope.
| Field | Range | Default | Feel |
| --- | --- | --- | --- |
| `walkSpeed` | 1 to 8 m/s | 3.2 | A gentle stick. |
| `runSpeed` | 2 to 14 m/s | 6.5 | A full stick, or `sprint`. |
| `acceleration` | 8 to 80 m/s² | 32 | How snappy starts and stops are. Releasing the stick brakes 1.4 times harder. |
| `airControl` | 0 to 1 | 0.35 | Share of `acceleration` you get in the air. |
| `jumpHeight` | 0 to 4 m | 1.6 | 0 turns jumping off. |
| `gravity` | 5 to 40 m/s² | 22 | High gravity with a high jump reads as arcade. |
| `maxFallSpeed` | 5 to 40 m/s | 22 | |
| `coyoteTime` | 0 to 0.3 s | 0.12 | A jump still works this long after walking off an edge. |
| `jumpBuffer` | 0 to 0.3 s | 0.15 | A press this long before landing fires on landing. |
| `radius` | 0.2 to 1 m | 0.4 | Body capsule. |
| `height` | 0.8 to 3 m | 1.7 | Body capsule. |
| `stepHeight` | 0 to 0.6 m | 0.35 | Ledges this high are stepped onto instead of bumped into. |
| `turnRate` | 2 to 30 rad/s | 12 | How fast the body turns to face its motion. |
`CharacterParams` has every field above plus `eyeHeight` (0.9 times `height`).
With the defaults, a running jump clears about 4 m across and 0.7 m up. Keep platform gaps under 3 m and rises under 0.8 m, or raise `jumpHeight`.
### `CharacterCommand`
What a body should do this frame. The player's thumbs, a bot brain and the autopilot all write the same object, so they all obey the same physics. Create one with `createCommand()` and empty it with `clearCommand(cmd)`.
| Field | Type | Meaning |
| --- | --- | --- |
| `moveX`, `moveY` | number | Stick on the unit disc, +y forward. |
| `lookDX`, `lookDY` | number | Look turn this frame, radians. +x turns left, +y looks down. |
| `jump` | boolean | True for the one frame it was pressed. |
| `actionA`, `actionB` | boolean | True for the one frame they were pressed. The controller ignores them: your rules read them. |
| `sprint` | boolean | Held: forces run speed whatever the stick magnitude. |
### `CharacterState`
Create one with `createCharacterState(spawn)` (a `Partial`; it starts grounded and still). Keep it in a ref.
| Field | Meaning |
| --- | --- |
| `x`, `y`, `z` | Feet position. |
| `vx`, `vy`, `vz` | Velocity. |
| `yaw` | Body facing. |
| `lookYaw`, `lookPitch` | Where the camera looks. Pitch is clamped to -1.2 to 1.25 rad, + looks down. |
| `grounded`, `hitWall` | Contact this frame. |
| `standingOn` | The `ResolvedBox` under the feet, or `null` for the floor or the air. |
| `speed` | Planar speed, m/s. |
| `airTime` | Seconds since last on the ground. |
| `jumped`, `landed` | True on the one frame a jump started or the body landed. Use them for sounds, dust and bot noise. |
| `coyote`, `jumpBuffer` | Timers the controller keeps. |
| `ledge` | `'leap'`, `'hang'`, `'climb'` or `null`, when you use ledges. |
### Functions
| Function | What it does |
| --- | --- |
| `stepCharacter(state, cmd, dt, level, params, frame, collision?)` | One frame of motion. Mutates `state`. Clamps `dt` to 1/30 s and is frame-rate independent below that. Pass a `CollisionResult` to reuse your own. |
| `respawnCharacter(state, point)` | Puts the body at a point, standing still. Sets `yaw` when the point has one; keeps the look direction. |
| `speedForStick(magnitude, params, sprint)` | The planar speed a stick magnitude asks for: 0 under 0.02, walk speed scaled up to 0.6, then blended to run speed at 1. |
| `forwardOf(yaw)`, `rightOf(yaw)` | Unit vectors `{ x, z }`. |
| `capsuleOf(params)` | The collision capsule. |
What one step does, in order: it turns the look by the command's look delta; turns the stick into a wanted velocity in the frame's forward and right directions and accelerates toward it (`airControl` in the air); starts a jump if one was pressed within `jumpBuffer` and the body is grounded or within `coyoteTime` of an edge, reaching exactly `jumpHeight` at any frame rate; applies gravity up to `maxFallSpeed`; moves and collides, removing the part of the velocity that goes into a wall so the body slides along it; then turns the body toward its motion at `turnRate`, or to the look direction in first person.
### `MoveFrame`
`MoveFrame` is `{ forwardYaw, faceLook, axis }`: what the stick's forward means, whether the body faces the look direction (first person), and `'free'` or `'x'` (side view ignores the stick's vertical axis). You rarely build one by hand: `moveFrameFor(rig, state, out)` writes the right one for a camera, and `WORLD_FRAME` (`forwardYaw: 0`, stick up is +Z) is the starting value to copy into a ref.
## Ledges: leap, hang and climb
A wall too tall to clear with a jump is a cliff. Jumping at one becomes a leap with the arms reaching up; the hands catch the edge and the body hangs. Then the player decides: JUMP or the stick toward the wall climbs, the stick away lets go. Call `stepLedge` first and skip `stepCharacter` while it returns true:
```ts
import {
createLedge,
leapAtCliff,
stepCharacter,
stepLedge,
type CharacterCommand,
type CharacterParams,
type CharacterState,
type Level,
type LedgeState,
type MoveFrame,
} from '@/engine';
export function stepWithLedges(
ledge: LedgeState, // createLedge(), kept in a ref
s: CharacterState,
cmd: CharacterCommand,
dt: number,
level: Level,
params: CharacterParams,
frame: MoveFrame,
) {
if (!stepLedge(ledge, s, cmd, level, params, frame, dt)) {
stepCharacter(s, cmd, dt, level, params, frame);
leapAtCliff(ledge, s, level, params);
}
}
```
A top can be caught when it is between `minReach` and `maxReach` above the feet and higher above the ground you jumped from than `jumpHeight`. Only boxes that are not `wall` and not hidden have ledges, so make cliffs out of `platform` (or `block`) boxes. A body standing on the ground never catches a ledge.
| `LedgeConfig` field | Default | Meaning |
| --- | --- | --- |
| `minReach` | 1.1 m | Lowest ledge above the feet; anything lower is a step. |
| `maxReach` | 2.25 m | Highest ledge above the feet. |
| `gap` | 0.3 m | How close to the wall the body must be, beyond its radius. |
| `hangDrop` | 1.8 m | Feet below the ledge while hanging. |
| `hangOff` | 0.32 m | Chest off the wall while hanging. |
| `climbTime` | 1.58 s | Seconds to climb. Match it to the climb clip. |
`stepLedge(ledge, s, cmd, level, params, frame, dt, config?)` and `leapAtCliff(ledge, s, level, params, range = 3, config?)` both take the config last. `findLedge(level, s, params, config?, groundY?)` returns the nearest edge the body could catch right now. `CLIMB_PATH` and `climbPathAt(t)` describe the path of the body up the edge, which the climb clip is posed along.
A driven `CharacterMesh` plays `hang`, `climb` and `leap` clips from `state.ledge` when the model has them. The capsule fallback has no clips, so it hangs and climbs as a sliding capsule.
## Drawing the body: `CharacterMesh`
`CharacterMesh` draws a capsule with a visor and a belt so its facing reads from any camera, or a generated model in its place. The feet are at the group origin and the nose points to +Z, so `group.rotation.y` is the body yaw. It never moves itself: set `group.position` and `group.rotation.y` every frame from the state.
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `color` | string | required | Body colour. |
| `accent` | string | `#F3F4FF` | Belt and shoulder stripe. |
| `radius` | number | 0.4 | Pass `params.radius`. |
| `height` | number | 1.7 | Pass `params.height`. |
| `hidden` | boolean | false | Hide the body, for first person. |
| `emissive` | string | none | A glow (a chasing bot, the player who is "it"). |
| `model` | number | none | A bundled `.glb` (`require('../../assets/models/hero.glb')`). The capsule shows until it loads. |
| `animation` | string | none | The model's clip to play. Ignored while `drive` is set. |
| `clips` | `Record` | none | Extra clips from sibling files. |
| `modelYaw` | number | 0 | Turns the model to face +Z when it was made facing another way. |
| `drive` | `{ current: CharacterState }` | none | Animates the model from the controller: idle, walk, run by speed, then jump, fall, land, hang, climb and leap, from whichever clips it has. |
| `speeds` | `{ walk, run }` | `{ walk: 3, run: 6.5 }` | Blend speeds. Pass the controller's `walkSpeed` and `runSpeed`. |
| `motion` | `MotionConfig` | see below | How the body reads the controller. |
| `onAnimationEvent` | `(event: string) => void` | none | Clip events from the driven graph, such as `footstep`. |
| `footIK` | `Level` | none | Plants the model's feet on ramps and steps (needs `drive`). |
| `lookAt` | `() => Vec3 or null` | none | Turns the head toward a point while it returns one. Capsules turn their visor. |
The ref is a `CharacterHandle`:
| Member | Meaning |
| --- | --- |
| `group` | The `THREE.Group` to position. |
| `yaw` | The smoothly turned heading (with `drive`). Draw with `group.rotation.y = handle.yaw` for a body that turns like a body instead of snapping. |
| `hardLanding` | True for the one frame of a landing hard enough to show. Use it for squash and shake. |
| `resetMotion()` | After a respawn: stand facing the state's yaw now, with no leftover speed or turn. |
| `overlay(clip, layer = 'upper')` | Plays a clip once over the base motion (`hit`, `wave`). Does nothing without a driven model. |
```tsx
import { CharacterMesh, type CharacterHandle, type CharacterParams, type CharacterState } from '@/engine';
const Hero = ({ body, params, handle }: { body: { current: CharacterState }; params: CharacterParams; handle: React.RefObject }) => (
{
if (e === 'footstep') {
// play a step sound
}
}}
/>
);
```
Models, clips and the animation graph are covered in [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md).
### Motion: how the body reads the controller
The controller is exact and instant, which is right for gameplay and wrong for animation. `stepMotion` sits between them, and a driven `CharacterMesh` runs it for you. It holds a stop and start gap so speed never flickers near zero and settles on exactly 0 (so the walk clip gets no weight), turns the heading smoothly, and only reports a hard landing after a real drop.
| `MotionConfig` field | Default | Meaning |
| --- | --- | --- |
| `stopBelow` | 0.35 m/s | A moving body below this counts as stopped. |
| `startAbove` | 0.7 m/s | A stopped body above this counts as moving. |
| `response` | 12 | How fast the animated speed follows the real one, per second. |
| `softLandBelow` | 7 m/s | Landing slower than this is soft: no landing clip, no `hardLanding`. About a 2.5 m drop. |
| `turnRate` | 14 | How fast the drawn heading turns, per second. |
`createMotion(yaw?)` and `stepMotion(motion, state, dt, config?)` are exported for bots and custom meshes; `MotionState` is `{ speed, moving, yaw, hardLanding, fallSpeed }`. `pickMotionClips(names)` finds the `idle`, `walk`, `run`, `jump`, `fall`, `land`, `hang`, `climb` and `leap` clips by name, and treats a model's own clip with an unrecognised name (exporters call it "Animation") as its idle.
## Cameras
Four rigs. Resolve one with `resolveCameraRig(mode, config)` and give it to `CharacterCamera`.
| Mode | What it does | Stick frame |
| --- | --- | --- |
| `'third'` | Orbits behind the body. Dragging the look pad turns it. Pulls in when the level blocks the view, never closer than 1.1 m. Widens its field of view with speed. | Camera-relative. |
| `'first'` | At eye height, looking along the look direction. Hide the body with `hidden`. | Camera-relative; the body faces the look. |
| `'top'` | High and tilted, at a fixed yaw. | World axes: stick up is +Z at `yaw` 0. |
| `'side'` | Side view at a fixed yaw. | World x axis only: the stick's vertical axis does nothing. |
### `CameraRigConfig`
Defaults are listed as third / first / top / side. A dash means the mode ignores the field.
| Field | Range | Default | Meaning |
| --- | --- | --- | --- |
| `distance` | third 2 to 12, top 6 to 40, side 4 to 30 m | 6 / - / 16 / 14 | Metres from the pivot. |
| `pivot` | 0.3 to 1.2 | 0.85 / 0.9 / 0.5 / 0.5 | Pivot height as a share of body height. |
| `pitch` | third -20 to 60, top 30 to 89, side -10 to 30 degrees | 14 / - / 62 / 6 | Degrees looking down. In third person the look pitch adds to it. |
| `fov` | 30 to 110 | 60 / 78 / 50 / 45 | Field of view, degrees. |
| `lag` | 1 to 40 | 9 / 40 / 6 / 7 | Follow smoothing; higher is tighter. |
| `shoulder` | -2 to 2 m | 0 / - / - / - | Sideways offset of the pivot, + right. 0.5 to 0.9 feels over the shoulder. |
| `yaw` | degrees | - / - / 0 / 0 | Fixed yaw for top and side. 0 looks along +Z. |
| `speedFov` | 0 to 20 | 8 / 0 / 0 / 0 | Extra field of view at full run speed, degrees. |
`resolveCameraRig` returns a `CameraRig` with the same fields plus `mode`, angles in radians. Pulse Arena uses an over-the-shoulder rig:
```ts
import { resolveCameraRig } from '@/engine';
const rig = resolveCameraRig('third', { shoulder: 0.9, distance: 5.6, pitch: 10, speedFov: 4 });
```
### `CharacterCamera`
Put it inside the canvas. It drives the R3F camera every frame with frame-rate independent smoothing.
| Prop | Type | Notes |
| --- | --- | --- |
| `rig` | `CameraRig` | When the rig object changes (a mode switch), the camera cuts to the new pose instead of swinging through the level. Keep it in `useMemo`. |
| `target` | `{ current: CharacterState }` | The body to follow. |
| `level` | `Level` | For the third-person pull-in. |
| `bodyHeight` | number | `params.height`. |
| `speedRatio` | `() => number` | 0 to 1, widens the field of view while running. |
| `snapCount` | `{ current: number }` | Increase `current` to cut to the new pose once, with no smoothing. Use it after a respawn. |
Helpers for a camera button: `CAMERA_MODES` (`['third', 'first', 'top', 'side']`), `nextCameraMode(mode)` and `cameraModeLabel(mode)` (`'third person'`, `'first person'`, `'top-down'`, `'side view'`). Rooftop Hop keeps the mode in React state and builds the rig with `useMemo(() => resolveCameraRig(cameraMode), [cameraMode])`.
`computeCameraPose(rig, state, level, bodyHeight, speedRatio, out)` and `createCameraPose()` give you the pose without touching a camera, for a custom camera or a test.
### Respawning cleanly
Coming back should feel like a cut. After `respawnCharacter`, set `yaw`, `lookYaw` and `lookPitch` on the state, call `handle.resetMotion()`, and bump the `snapCount` ref you pass to `CharacterCamera`:
```ts
import { respawnCharacter, type CharacterHandle, type CharacterState, type ResolvedPoint } from '@/engine';
export function respawn(s: CharacterState, at: ResolvedPoint, mesh: CharacterHandle | null, snapCount: { current: number }) {
respawnCharacter(s, at);
s.lookYaw = at.yaw;
s.lookPitch = 0;
mesh?.resetMotion();
snapCount.current++;
}
```
### Camera shots
A shot is a moment when the camera stops following behind and frames something: the side of a hang so you see the drop, a vista from a clifftop. Give `stepShots` this frame's shot, or `null` to follow again; it eases both ways. Call it after the character steps.
```ts
import { createShotDirector, hangShot, stepShots, zoneShot, type CameraRig, type CameraZone, type CharacterState } from '@/engine';
const VISTAS: CameraZone[] = [{ x: 0, z: 40, radius: 4, yaw: Math.PI, pitch: 20, distance: 9 }];
const director = createShotDirector();
export function frameShots(s: CharacterState, rig: CameraRig, dt: number) {
const shot = s.ledge === 'hang' ? hangShot(s.yaw) : zoneShot(VISTAS, s);
stepShots(director, shot, s, rig, dt);
}
```
| Export | Meaning |
| --- | --- |
| `CameraShot` | `{ yaw, pitch, distance }`: yaw in radians (like `lookYaw`), pitch in degrees, distance in metres. |
| `CameraZone` | A `CameraShot` plus `{ x, z, radius }`: a place in the level with its own shot. |
| `zoneShot(zones, state)` | The first zone the body is in, or `null`. |
| `hangShot(wallYaw, side = 1)` | The camera beside a wall, looking along it. `side` picks which way it stands. While hanging, `state.yaw` faces the wall. |
| `stepShots(director, shot, state, rig, dt, rate = 3.2)` | Eases toward the shot. Returns true while a shot has the camera. It changes `rig.distance` and the state's look, so pass the same rig object `CharacterCamera` uses. |
| `createShotDirector()` | The `ShotDirector` state: `{ blend, homeDistance, homePitch }`. |
## Controls
### `CharacterControls`
The multi-touch control layer for character games. One gesture tracks every finger, so moving, looking and jumping happen at once and fingers may slide between buttons. The first finger on the stick half becomes a floating stick where it lands, fingers on the other half drag to look, and buttons sit in the bottom corner opposite the stick. It writes into a `CharacterInput` and never re-renders React per frame.
Render it over the canvas and render the HUD over it, so HUD buttons still take taps:
```tsx
import { StyleSheet, View } from 'react-native';
import { CharacterControls, createCharacterInput, type ControlButton } from '@/engine';
const input = createCharacterInput();
const BUTTONS: ControlButton[] = [
{ id: 'jump', label: 'JUMP', accessibilityLabel: 'Jump', size: 92 },
{ id: 'a', label: 'DASH', accessibilityLabel: 'Dash' },
];
export const Controls = ({ over }: { over: boolean }) => (
);
```
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `input` | `CharacterInput` | required | Create once with `createCharacterInput()`. |
| `disabled` | boolean | false | Ignore touches and keys (a finish screen). |
| `stick` | `'left'` or `'right'` | `'left'` | Which half the stick lives in. `'right'` for left-handed players. |
| `look` | boolean | true | Dragging on the other half turns the camera. `false` hides the look zone and touches there do nothing (for `top` and `side` cameras, or a 2D game); buttons with `look: true` still aim. |
| `buttons` | `ControlButton[]` | one JUMP button, 92 pt | |
| `stickRadius` | number | 56 | Stick travel in points, clamped to 40 to 90. |
| `deadZone` | number | 0.12 | Share of the radius ignored, rescaled so there is no jump. |
| `secondThumb` | boolean | false | No fixed buttons: the first touch anywhere is the stick, a second touch while it is held presses the first button under that thumb, and dragging that thumb turns the camera. |
| `lookArea` | `{ left, top, right, bottom }` | `{ left: 0.26, top: 0.32, right: 0.74, bottom: 0.72 }` | With `secondThumb`: where a first touch looks around instead of moving, as shares of the screen. |
| `onTouchCount` | `(count: number) => void` | none | Fingers down, for dev telemetry. |
A `ControlButton` is `{ id, label, accessibilityLabel?, size?, color?, look? }`. `size` is the diameter in points (default 76). With `look: true` the finger on the button also turns the camera while it drags, and the button stays held until that finger lifts, wherever it slides (a shooter's FIRE: hold to shoot, drag to aim); fingers on other buttons slide between buttons and release when they leave them all. The ids `jump`, `sprint`, `a` and `b` map onto the command (see `consumeInput`); any other id you read from `input.held[id]` and `input.pressed[id]` yourself.
Test IDs for device automation: `character-controls` (the layer), `stick-zone` (label "Move stick"), `look-zone` (label "Look"), `btn-` for each button (its `accessibilityLabel`, or its `label`), and `btn-thumb` with `secondThumb`.
### `CharacterInput` and `consumeInput`
`CharacterInput` is what the thumbs did since the loop last looked: `{ moveX, moveY, lookDX, lookDY, held, pressed }`. `lookDX` and `lookDY` are pixels of drag; `held` and `pressed` are records by button id.
| Function | What it does |
| --- | --- |
| `createCharacterInput()` | A new, empty input. |
| `consumeInput(input, cmd, lookSensitivity?)` | Writes this frame's command and clears what it consumed: the look deltas and every `pressed` flag. `jump`, `actionA` and `actionB` come from `pressed.jump`, `pressed.a` and `pressed.b`; `sprint` from `held.sprint`. Dragging right turns the view right. |
| `resetCharacterInput(input)` | Zeroes everything, for a restart. |
| `DEFAULT_LOOK_SENSITIVITY` | 0.0048 radians per pixel of drag, the default for `consumeInput`. |
### Keyboard in a desktop browser
On the web, `CharacterControls` also listens to the keyboard and feeds the same input, so a game built on it plays with keys without extra code. On phones no listener is added.
| Keys | Action |
| --- | --- |
| WASD or the arrow keys | The stick (eight directions, full tilt). A thumb on the stick wins over the keys. |
| Space | The first button in `buttons`. |
| J, E or F | The second button. |
| K or Q | The third button. |
| L | The fourth button. |
| Shift | A button with id `sprint`, wherever it is listed. |
| Mouse drag on the look half | Turns the camera. |
Keys are ignored while focus is in a text field or a modifier (Cmd, Ctrl, Alt) is held, and everything is released when the tab loses focus. See [Input](https://docs.moonspell.app/engine/input.md) for the keyboard layer shared by all controls.
### A joystick instead
For top-down games where the whole screen is the stick, `` also writes `moveX` and `moveY` into a `CharacterInput`, and `consumeInput` reads it the same way. The [kingdom module](https://docs.moonspell.app/modules/kingdom.md) drives its sorties this way with a `top` camera. The joystick's props are on the [Input](https://docs.moonspell.app/engine/input.md) page.
## Dev autopilot
`waypointCommand` writes a command that walks a body along a list of points. It runs on straights, slows near a point, and jumps when the ground ahead drops away, when the next point is higher, when a wall blocks it, or when it has been stuck for four seconds. It returns true once the last point is reached. Behind `__DEV__` and an AUTO button, it lets a script play the game with no thumb. Rooftop Hop walks the platform tops in order:
```ts
import { createWalker, resolveLevel, waypointCommand, type Waypoint } from '@/engine';
const LEVEL = resolveLevel({ preset: 'course', seed: 11 });
/** Platform tops in order, then the goal. */
const WAYPOINTS: Waypoint[] = [
...LEVEL.boxes
.filter((b) => b.kind === 'platform')
.sort((a, b) => a.minZ - b.minZ)
.map((b) => ({ x: (b.minX + b.maxX) / 2, y: b.maxY, z: (b.minZ + b.maxZ) / 2 })),
...(LEVEL.goal ? [{ x: LEVEL.goal.x, y: LEVEL.goal.y, z: LEVEL.goal.z }] : []),
];
const walker = createWalker();
// In useFrame, instead of consumeInput:
// waypointCommand(state, LEVEL, WAYPOINTS, walker, PARAMS, frame, cmd, dt);
```
| Export | Meaning |
| --- | --- |
| `waypointCommand(state, level, points, walker, params, frame, cmd, dt)` | Writes `cmd`. A point counts as reached within 0.7 m across and 1.2 m up or down. |
| `createWalker()` | A `WaypointWalker`: `{ index, stuck }`. Set `index` to resume from a point after a respawn. |
| `worldDirToStick(dx, dz, frame, cmd, magnitude = 1)` | Writes the stick that moves a body toward a world direction. Bots use it too. |
| `Waypoint` | `{ x, y, z }`. |
## Extras: ripples and birds
`` spreads rings on the ground from a small pool of flat meshes: ink from a landing, a shockwave, a splash. Props: `color` (`#0B0B0D`) and `count` (8 at once). The ref is a `RipplesHandle` with `play(x, y, z, strength = 0.5)`; strength 0 to 1 sets the size and darkness, and above 0.6 a second ring follows.
`` places birds that sit until the player comes near, then burst away. It costs two draw calls whatever the count.
| `Flock` prop | Default | Meaning |
| --- | --- | --- |
| `perches` | required | `[x, y, z]` per bird. |
| `near` | required | `() => { x, y, z }`, usually `() => body.current`. |
| `radius` | 6 m | Flush when something is this close. |
| `life` | 7 s | Seconds of flight before a bird is gone. |
| `color` | `#0D0D0F` | |
| `size` | 0.5 m | Bird length. |
| `onFlush` | none | Called once when they take off: play the wings here. |
```tsx
import { useRef } from 'react';
import { Flock, Ripples, type CharacterState, type RipplesHandle } from '@/engine';
export const Life = ({ body }: { body: { current: CharacterState } }) => {
const rings = useRef(null);
// after a hard landing: rings.current?.play(s.x, s.y, s.z, 0.8);
return (
<>
body.current} />
>
);
};
```
`createFlock(perches)` and `stepFlock(flock, near, dt, config?)` are the logic without React.
## Export reference
All from `@/engine`.
| Area | Values | Types |
| --- | --- | --- |
| Level | `resolveLevel`, `groundHeightAt`, `LEVEL_PRESETS`, `PRESET_SIZE`, `buildPreset`, `resolvePiece`, `RAMP_STEP`, `STAIR_STEP`, `gridMap`, `DEFAULT_LEGEND`, `LevelMeshes`, `Extruded`, `Lathed` | `LevelDef`, `Level`, `LevelBox`, `LevelPiece`, `LevelPoint`, `LevelPreset`, `LevelTheme`, `BoxKind`, `Facing`, `ResolvedBox`, `ResolvedPiece`, `ResolvedPoint`, `Vec3`, `GridMapOptions`, `GridCell`, `ExtrudedProps`, `LathedProps` |
| Collision | `collideCapsule`, `createCollisionResult`, `raycastLevel`, `segmentClear` | `Capsule`, `CollisionResult`, `RayHit` |
| Controller | `resolveCharacterParams`, `createCharacterState`, `respawnCharacter`, `stepCharacter`, `speedForStick`, `capsuleOf`, `forwardOf`, `rightOf`, `createCommand`, `clearCommand`, `WORLD_FRAME` | `CharacterCommand`, `CharacterConfig`, `CharacterParams`, `CharacterState`, `MoveFrame` |
| Ledges | `createLedge`, `stepLedge`, `leapAtCliff`, `findLedge`, `CLIMB_PATH`, `climbPathAt` | `LedgeConfig`, `LedgeState` |
| Motion and mesh | `CharacterMesh`, `createMotion`, `stepMotion`, `pickMotionClips` | `CharacterHandle`, `MotionConfig`, `MotionInput`, `MotionState` |
| Cameras | `resolveCameraRig`, `CharacterCamera`, `computeCameraPose`, `createCameraPose`, `moveFrameFor`, `CAMERA_MODES`, `cameraModeLabel`, `nextCameraMode` | `CameraMode`, `CameraRig`, `CameraRigConfig`, `CameraPose` |
| Shots | `createShotDirector`, `stepShots`, `zoneShot`, `hangShot` | `CameraShot`, `CameraZone`, `ShotDirector` |
| Controls | `CharacterControls`, `createCharacterInput`, `resetCharacterInput`, `consumeInput`, `DEFAULT_LOOK_SENSITIVITY` | `CharacterInput`, `ControlButton`, `StickSide` |
| Autopilot | `waypointCommand`, `createWalker`, `worldDirToStick` | `Waypoint`, `WaypointWalker` |
| Extras | `Ripples`, `Flock`, `createFlock`, `stepFlock` | `RipplesHandle`, `Bird`, `FlockConfig`, `FlockState` |
## Gotchas
- **The engine never respawns or finishes for you.** Check `state.y < level.killY` yourself, and use zones for checkpoints and goals (Rooftop Hop does both). Zones, paths and `MovingPlatform` are on the [Utilities](https://docs.moonspell.app/engine/utilities.md) page.
- **`CharacterMesh` does not follow the state.** Set `group.position` and `group.rotation.y` in your `useFrame` after `stepCharacter`, or the body stays at the origin.
- **Keep the rig stable.** `CharacterCamera` cuts whenever the `rig` object changes. Build it with `useMemo` or at module scope; a new object every render makes the camera snap every render.
- **Read custom buttons before `consumeInput`.** It clears every `pressed` flag, including ids it does not know. Read `input.pressed.myButton` first, or read `input.held` at any time.
- **Button order sets the keys.** On the web, Space presses the first button in `buttons`, not the one with id `jump`. Pulse Arena lists FIRE first, so Space fires there. Put JUMP first if Space should jump.
- **`speeds` on `CharacterMesh` defaults to 3 and 6.5,** not to the controller's 3.2 walk speed. Pass `{ walk: params.walkSpeed, run: params.runSpeed }` when you change them.
- **`LevelMeshes` reads `nextCheckpoint` at render.** A ref's value does not re-render it; the rings dim when the component renders again.
- **Levels are boxes.** Round or sloped scenery collides as its boxes: a cylinder as a box 0.85 of its radius each side, a ramp as 0.1 m steps. `Extruded`, `Lathed` and plain meshes do not collide at all until you add a box or a piece.
- **Doors and bots.** The player fits through any door wider than the body, but bots path on a grid: doors narrower than about a cell plus a body are closed to them. See [Bots](https://docs.moonspell.app/engine/bots.md).
- **Clamp `dt`.** `stepCharacter` clamps to 1/30 s on its own, but clamp in your loop too so your timers and the step agree.
## See also
- [Examples](https://docs.moonspell.app/examples.md): Rooftop Hop (this page in full), Lantern Tag (bots, `gridMap`), Pulse Arena (combat).
- [Input](https://docs.moonspell.app/engine/input.md): touch controls, `Joystick` and the keyboard layer.
- [Physics](https://docs.moonspell.app/engine/physics.md): rigid bodies the character can shove.
- [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md): generated characters, clips, the animation graph and instanced scenery.
- [Bots](https://docs.moonspell.app/engine/bots.md): bots that drive the same controller.
- [Combat](https://docs.moonspell.app/engine/combat.md): health, weapons and aim for character games.
- [Game feel](https://docs.moonspell.app/engine/game-feel.md): `juice('land')`, squash and stretch for jumps and landings.
- [Kingdom module](https://docs.moonspell.app/modules/kingdom.md) and [Shooter module](https://docs.moonspell.app/modules/shooter.md): modules built on this foundation.
---
# Combat
Source: https://docs.moonspell.app/engine/combat
> Health, armour, weapons as data, hitscan and projectile shots, explosions, aim assist, status effects, kill credit, respawns, bots that shoot back, and the effects and HUD that draw it all.
The combat kit (`src/engine/combat/`) is everything a game needs for characters that shoot or hit each other: health, weapons, hits, explosions, respawns, a score and a killfeed. Use it for any 3D fight built on the [character controller](https://docs.moonspell.app/engine/character-and-cameras.md): an arena shooter, paintball, laser tag, a sword duel, a wave survival game.
The core is `CombatWorld`, a plain TypeScript class with no React and no three.js. You call it from your frame loop and read its lists to draw. Bots from the [bots kit](https://docs.moonspell.app/engine/bots.md) get a trigger finger through `createGunner` and `driveBotWeapon`.
If you want a ready shooter with modes (free-for-all, team deathmatch, gun game, capture the flag, king of the hill), use the [shooter module](https://docs.moonspell.app/modules/shooter.md), which is built on this kit. Use the kit directly when the module's modes do not fit your rules.
## Smallest working example
A free-for-all against two armed bots. This is `examples/pulse-arena/` cut down to the parts that touch combat. The level, the player body and the camera follow the [character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md) page; read it first if those lines are new to you.
```tsx
import React, { useMemo, useRef, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import {
AmmoCounter,
BotMeshes,
BotSquad,
CharacterCamera,
CharacterControls,
CharacterMesh,
CombatEffects,
CombatWorld,
Crosshair,
DamageNumbers,
EngineCanvas,
HealthBar,
Killfeed,
LevelMeshes,
TopBar,
WORLD_FRAME,
applyEffectsToCommand,
computeCameraPose,
consumeInput,
createCameraPose,
createCharacterInput,
createCharacterState,
createCommand,
createGunner,
createRoster,
driveBotWeapon,
isReloading,
moveFrameFor,
resolveCameraRig,
resolveCharacterParams,
resolveLevel,
spreadOf,
stepCharacter,
type Bot,
type CharacterHandle,
type CharacterInput,
type ControlButton,
type FeedEntry,
type GameDefinition,
type GameScreenProps,
type Gunner,
type ScreenNumber,
} from '@/engine';
const LEVEL = resolveLevel({ preset: 'arena', size: 24, seed: 7 });
const PARAMS = resolveCharacterParams({ runSpeed: 6.8, walkSpeed: 3.2 });
const BUTTONS: ControlButton[] = [
{ id: 'fire', label: 'FIRE', accessibilityLabel: 'Fire', size: 100, look: true },
{ id: 'jump', label: 'JUMP', accessibilityLabel: 'Jump', size: 72 },
];
interface Hud {
hp: number;
max: number;
ammo: number;
magazine: number;
reloading: boolean;
weapon: string;
spread: number;
locked: boolean;
kills: number;
feed: FeedEntry[];
}
const makeMatch = () => {
const body = createCharacterState(LEVEL.spawns[0]);
const squad = new BotSquad({ level: LEVEL, params: PARAMS });
// The world shares the squad's noise bus, so bots hear every shot.
const world = new CombatWorld({ level: LEVEL, noise: squad.noise, respawnSeconds: 2.5 });
world.add({ id: 'player', name: 'You', color: '#7C5CFF', body, params: PARAMS, weapon: 'rifle', health: { regenPerSecond: 12 } });
const roster = createRoster({ targetPlayers: 3, maxBots: 2 });
roster.reconcile(1);
const gunners: Record = {};
roster.bots.forEach((b, i) => {
const spawn = LEVEL.spawns[(i + 1) % LEVEL.spawns.length];
const bot = squad.add({ id: b.id, name: b.name, color: b.color, role: 'skirmisher', spawn: { x: spawn.x, z: spawn.z, yaw: spawn.yaw } });
world.add({ id: b.id, name: b.name, color: b.color, body: bot.state, params: bot.params, weapon: 'smg', bot: true });
gunners[b.id] = createGunner('normal', { fleeBelow: 0.25 });
});
return { body, squad, world, gunners };
};
type Match = ReturnType;
const Simulation = ({ match, input, numbers, onHud }: { match: Match; input: CharacterInput; numbers: ScreenNumber[]; onHud: (h: Hud) => void }) => {
const { squad, world, gunners } = match;
const body = useRef(match.body);
const rig = useMemo(() => resolveCameraRig('third'), []);
const mesh = useRef(null);
const cmd = useRef(createCommand());
const frame = useRef({ ...WORLD_FRAME });
const pose = useRef(createCameraPose());
const view = useRef({ origin: { x: 0, y: 0, z: 0 }, dir: { x: 0, y: 0, z: 1 }, shot: { x: 0, y: 0, z: 1 } });
const clock = useRef(0);
const lastHud = useRef(0);
useFrame((_, rawDt) => {
const dt = Math.min(rawDt, 1 / 30);
clock.current += dt;
const now = clock.current;
const s = body.current;
const me = world.get('player')!;
// 1. Read the fire button before consumeInput clears this frame's presses.
const fireHeld = !!input.held.fire;
const firePressed = !!input.pressed.fire;
consumeInput(input, cmd.current);
// 2. Move the player, with slows and stuns applied first.
applyEffectsToCommand(cmd.current, me);
stepCharacter(s, cmd.current, dt, LEVEL, PARAMS, moveFrameFor(rig, s, frame.current));
// 3. Move the bots. Dead bots sit out; each bot targets everyone else.
for (const bot of squad.bots) bot.disabled = !!world.get(bot.id)?.health.dead;
squad.step(dt, now, (bot: Bot) => world.targetsFor(bot.id));
// 4. Aim along the camera ray (aim assist included) and pull the trigger.
const p = computeCameraPose(rig, s, LEVEL, PARAMS.height, 0, pose.current);
const v = view.current;
v.origin.x = p.x;
v.origin.y = p.y;
v.origin.z = p.z;
const len = Math.hypot(p.tx - p.x, p.ty - p.y, p.tz - p.z) || 1;
v.dir.x = (p.tx - p.x) / len;
v.dir.y = (p.ty - p.y) / len;
v.dir.z = (p.tz - p.z) / len;
let locked = false;
if (!me.health.dead) {
locked = world.aimFromView('player', v.origin, v.dir, v.shot).id !== null;
world.pullTrigger('player', fireHeld, firePressed, v.shot, now, dt);
}
// 5. Bots shoot back, then the world advances.
for (const bot of squad.bots) driveBotWeapon(world, bot, gunners[bot.id], now, dt);
world.step(dt, now);
world.drainEvents(); // play sounds and haptics here
if (mesh.current?.group) {
mesh.current.group.visible = !me.health.dead;
mesh.current.group.position.set(s.x, s.y, s.z);
mesh.current.group.rotation.y = s.lookYaw;
}
// The HUD is React Native: update it a few times a second, not every frame.
if (now - lastHud.current > 0.1) {
lastHud.current = now;
onHud({
hp: me.health.hp,
max: me.health.max,
ammo: me.weaponState.ammo,
magazine: me.weapon.magazine,
reloading: isReloading(me.weaponState, now),
weapon: me.weapon.name,
spread: spreadOf(me.weapon, me.weaponState, s.speed / PARAMS.runSpeed),
locked,
kills: me.kills,
feed: [...world.feed],
});
}
});
return (
<>
clock.current} numbersOut={numbers} />
>
);
};
const Arena = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const numbers = useMemo(() => [], []);
const [match] = useState(makeMatch);
const [hud, setHud] = useState(null);
return (
{hud && (
<>
>
)}
);
};
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' } });
const game: GameDefinition = { id: 'arena', title: 'Arena', description: 'Free-for-all against two bots.', Component: Arena };
export default game;
```
To run it, put it in `src/game/index.tsx` and start the game as in the [quickstart](https://docs.moonspell.app/start/quickstart.md). The full version with a win condition, weapon swap, a scoreboard, a best time and a dev autopilot is `examples/pulse-arena/index.tsx`.
## How a frame runs
The order inside your frame loop matters:
1. Read `input.held.fire` and `input.pressed.fire`, then call `consumeInput`. `consumeInput` clears the presses.
2. `applyEffectsToCommand(cmd, world.get('player'))`, then `stepCharacter` for the player.
3. Set `bot.disabled` for dead bots, then `squad.step(...)`.
4. `world.aimFromView(...)` turns the camera ray into a shot direction; `world.pullTrigger(...)` fires.
5. `driveBotWeapon(...)` for each bot.
6. `world.step(dt, now)`: projectiles, burns, regeneration, falls, respawns, fading effects.
7. `world.drainEvents()` for sounds, haptics and scoring.
`now` is your game clock in seconds. Use the same clock for every call.
## Health
Each combatant has a `Health` made from a `HealthConfig`. Armour takes a share of every hit until it runs out. Regeneration starts after a delay without damage. A combatant cannot be hurt for a short time after it spawns.
| Field | Range | Default | What it does |
|---|---|---|---|
| `max` | 1 to 10000 | `100` | Hit points. |
| `armor` | 0 to 1000 | `0` | Armour points on spawn. |
| `armorAbsorb` | 0 to 1 | `0.6` | Fraction of each hit the armour takes while it lasts. |
| `regenPerSecond` | 0 to 200 | `0` (off) | Hit points regained per second. |
| `regenDelay` | 0 to 30 | `4` | Seconds without damage before regeneration starts. |
| `spawnProtection` | 0 to 10 | `1.5` | Seconds of invulnerability after a spawn or respawn. |
The pure functions work on a `Health` without a world, for games that only need hit points:
| Function | What it does |
|---|---|
| `resolveHealth(config?, now?)` | Clamps a config and returns a full `Health`. |
| `applyDamage(h, amount, now)` | Removes hit points (armour first). Returns a `DamageResult`: `dealt`, `absorbed`, `killed`, `blocked`. Blocked means dead already, spawn-protected or zero damage. |
| `heal(h, amount)` | Adds hit points up to `max`. Does nothing to the dead. Returns the amount healed. |
| `addArmor(h, amount, cap?)` | Adds armour up to `cap` (default: the spawn armour, or `amount` when there was none). |
| `stepHealth(h, dt, now)` | Regeneration. Call every frame. |
| `reviveHealth(h, now)` | Full health, fresh armour, fresh spawn protection. |
| `isHostile(teamA, teamB, friendlyFire?)` | `null` team means free-for-all, so everyone is hostile. |
## Weapons
A weapon is a row of data. Pass a preset name, a preset plus overrides, or a full `WeaponConfig`. `resolveWeapon` fills the rest from the rifle and clamps every field.
| Preset | Kind | Mode | Damage | Shots per second | Notes |
|---|---|---|---|---|---|
| `pistol` | hitscan | semi | 22 | 4 | 12 rounds, accurate. |
| `rifle` | hitscan | auto | 14 | 8 | 30 rounds. The default. |
| `smg` | hitscan | auto | 9 | 13 | 35 rounds, 32 m, more aim assist. |
| `shotgun` | hitscan | semi | 11 per pellet, 8 pellets | 1.3 | 6 shells, 22 m, knockback 6. |
| `sniper` | hitscan | semi | 85, headshot x2 | 0.8 | 140 m, wide spread on the move, half aim assist. |
| `blaster` | projectile | auto | 18 | 5 | 34 m/s bolts you can dodge. |
| `rocket` | projectile | semi | 30, 4 m splash | 0.9 | Knockback 12. Hurts you too. |
| `grenade` | projectile | semi | 20, 4.5 m splash | 0.8 | Arcs, bounces, 1.6 s fuse, no aim assist. |
| `sword` | melee | semi | 45 | 1.8 | 2.4 m reach, 100 degree arc. |
```ts
import { WEAPON_PRESETS } from '@/engine';
// A preset with overrides, swapped in at runtime (a pickup):
world.equip('player', 'rifle', { damage: 20, color: '#FF5CF0' });
// A new weapon built from a preset, given at add time:
world.add({
id: 'player',
body,
params,
weapon: { ...WEAPON_PRESETS.smg, name: 'Paint gun', status: { kind: 'slow', seconds: 1, power: 0.6 } },
});
```
Every `WeaponConfig` field. Defaults are the rifle's (the base every preset starts from):
| Field | Range | Default | What it does |
|---|---|---|---|
| `name` | string | `'Rifle'` | Shown in the killfeed and `AmmoCounter`. |
| `kind` | `'hitscan'`, `'projectile'`, `'melee'` | `'hitscan'` | How shots travel. |
| `mode` | `'semi'`, `'auto'`, `'burst'` | `'auto'` | One shot per press, fire while held, or bursts. |
| `damage` | 1 to 500 | `14` | Per pellet at close range. |
| `headshotMultiplier` | 1 to 4 | `1.6` | Hitscan and projectiles only. |
| `fireRate` | 0.2 to 30 | `8` | Shots per second. |
| `pellets` | 1 to 20 | `1` | Rays or projectiles per shot. |
| `spreadDeg` | 0 to 30 | `1.2` | Cone of inaccuracy, full angle. |
| `moveSpreadDeg` | 0 to 30 | `2.5` | Extra spread at full run speed. |
| `bloomDeg` | 0 to 5 | `0.4` | Extra spread per shot while the trigger stays held. Recovers on release. |
| `range` | hitscan 5 to 200, melee 1 to 5 | `60` | Metres. For projectiles, the maximum flight distance. |
| `falloffStart` | 0 to 1 | `0.5` | Damage starts dropping at this fraction of `range`. |
| `falloffMin` | 0 to 1 | `0.6` | Fraction of damage left at full range. |
| `magazine` | 0 to 200 | `30` | Rounds per magazine. `0` never reloads. |
| `reloadSeconds` | 0.2 to 6 | `1.8` | |
| `burstCount` | 2 to 6 | `3` | Shots per press in `burst` mode. |
| `projectileSpeed` | 5 to 200 | `40` | m/s. |
| `projectileGravity` | 0 to 40 | `0` | m/s² pulling projectiles down. |
| `projectileRadius` | 0.05 to 1 | `0.12` | Visual and hit radius. |
| `splashRadius` | 0 to 12 | `0` | Explosion radius. `0` is no explosion. |
| `splashMinFraction` | 0 to 1 | `0.3` | Damage fraction at the edge of the blast. |
| `fuseSeconds` | 0.2 to 10 | `3` | Life of a bouncing or falling projectile before it explodes. |
| `bounce` | boolean | `false` | Projectiles bounce off walls instead of stopping (grenades). |
| `knockback` | 0 to 30 | `1` | Push given to whoever is hit, m/s. |
| `arcDeg` | 20 to 360 | `90` | Melee swing arc. |
| `loudness` | 0 to 1 | `0.8` | Posted to the noise bus per shot, so bots hear it. |
| `aimAssist` | 0 to 1.5 | `1` | Multiplies the world's aim assist cone for this weapon. |
| `status` | `StatusSpec` or `null` | `null` | Effect each hit leaves. See [Status effects](https://docs.moonspell.app/engine/combat.md#status-effects). |
| `color` | CSS colour | `'#FFE08A'` | Tracer and projectile colour. |
Reloads start by themselves on an empty magazine. Call `world.reload(id, now)` for a reload button.
The weapon functions work without a world too:
| Function | What it does |
|---|---|
| `WEAPON_PRESETS`, `WEAPON_PRESET_NAMES` | The table above, and its keys. |
| `resolveWeapon(presetOrConfig?, overrides?)` | Returns a clamped `Weapon`. Default `'rifle'`. |
| `createWeaponState(weapon)` | A `WeaponState`: `ammo` (Infinity when `magazine` is 0), `reloadingUntil`, `nextShotAt`, `burstLeft`, `bloom`, `shots`. |
| `triggerWeapon(state, weapon, now, held, pressed, dt?)` | Returns how many shots to fire this frame (at most 4), spends ammo, starts and finishes reloads. |
| `startReload(state, weapon, now)` | Returns whether a reload started. |
| `isReloading(state, now)`, `reloadProgress(state, weapon, now)` | Reload state, progress from 0 to 1. |
| `damageAt(weapon, distance)` | Damage of one pellet after falloff. |
| `spreadOf(weapon, state, speedRatio)` | Current cone in degrees: base plus movement plus bloom. `speedRatio` is 0 to 1 of run speed. |
## Hitscan
Hitscan shots are rays. The world uses these, and you can call them yourself for a line-of-fire check.
| Export | What it does |
|---|---|
| `hitscan(level, origin, dir, range, bodies, ignoreId?, out?)` | The nearest body or level surface along a unit ray. Returns a `ShotHit`: `kind` (`'body'`, `'level'`, `'none'`), `id`, `distance`, `x`, `y`, `z`, the surface normal `nx`, `ny`, `nz` (level hits only), `headshot`. |
| `raycastBody(origin, dir, maxDist, body)` | Distance along a ray to one vertical capsule, or `null`. |
| `createShotHit()` | An empty `ShotHit` to reuse as `out`. |
| `HEAD_FRACTION` | `0.8`: hits above 80% of a body's height are headshots. |
| `HitBody` | `{ id, x, y, z, radius, height }`, feet position plus capsule size. |
## Projectiles
`ProjectileSystem` moves bolts, rockets and grenades with swept segments, so fast projectiles never pass through thin walls or bodies. At most 96 are alive; spawning another drops the oldest. A projectile ignores its shooter for its first 0.25 seconds. `CombatWorld` owns one as `world.projectiles`; its `list` holds every `Projectile` in flight.
| Member | What it does |
|---|---|
| `spawn(by, weapon, origin, dir, now)` | Fires one projectile. |
| `step(dt, now, level, bodies, onEnd)` | Moves every projectile and calls `onEnd` with a `ProjectileEnd` (`kind` `'body'`, `'level'` or `'expired'`) for each that stopped. |
| `clear()` | Removes all. |
| `splashFraction(level, center, body, radius, minFraction)` | Explosion damage fraction for one body: 1 at the centre down to `minFraction` at the edge, 0 outside the radius or behind a wall. |
## Aim assist
On a phone, a thumb cannot aim like a mouse. Aim assist bends a shot toward the visible enemy nearest the crosshair, inside a cone. Bodies behind walls never count.
`CombatWorld` applies it in `aimFromView`, scaled by each weapon's `aimAssist`. Set it with the world's `aimAssist` option, or pass `false` to turn it off.
| `AimAssistConfig` field | Range | Default | What it does |
|---|---|---|---|
| `coneDeg` | 0 to 30 | `9` | Full cone in which a target pulls the aim. |
| `range` | metres | `60` (in `aimFromView`: the weapon's range) | Farthest target considered. |
| `strength` | 0 to 1 | `0.6` | How far the shot bends toward the target. |
| `aimHeight` | fraction | `0.6` | Point on the body to aim at (chest). |
The aim helpers, for your own shooting or bots:
| Function | What it does |
|---|---|
| `aimAssist(level, origin, dir, bodies, ignoreId, config?, out?)` | Writes the corrected direction into `out`. Returns `{ id, angleDeg }`, the body it locked on. |
| `aimDirection(yaw, pitch, out?)` | Unit view direction. Yaw 0 looks along +Z; positive pitch looks down. |
| `spreadDirection(dir, spreadDeg, rng, out?)` | A random direction inside a cone. |
| `leadTarget(from, target, vel, speed, out?)` | Where a moving target will be when a projectile arrives. |
| `angleBetween(a, b)` | Degrees between two unit vectors. |
| `normalize(v)` | Normalises a `Vec3` in place. |
## Status effects
A weapon's `status` leaves an effect on every hit. A second hit of the same kind refreshes it: the longer end and the stronger power win. Effects do not stack.
| `kind` | `power` | Default power | Effect |
|---|---|---|---|
| `'burn'` | damage per second, 1 to 200 | `10` | Damage over time. The kill goes to the shooter. |
| `'slow'` | speed factor, 0.1 to 0.95 | `0.5` | Multiplies movement. Sprint is turned off. |
| `'stun'` | ignored | | No moving, no jumping, no shooting. |
`seconds` is 0.1 to 20. Each frame the world writes what the effects do into `combatant.effects` (a `StatusFrame`: `damage`, `damageBy`, `speedScale`, `stunned`). For the player, call `applyEffectsToCommand(cmd, combatant)` before `stepCharacter`. It also freezes the command of a dead combatant. Bots get it through the squad and `driveBotWeapon`. `addStatus(list, spec, now, by?)` and `stepStatuses(list, dt, now, out?)` are the pure functions underneath.
## CombatWorld
`new CombatWorld(options)` holds everyone who can shoot or be shot in one game.
| Option | Type | Default | What it does |
|---|---|---|---|
| `level` | `Level` | required | The level from `resolveLevel`. Shots stop at its boxes. |
| `friendlyFire` | boolean | `false` | Team-mates can hurt each other. |
| `selfDamage` | boolean | `true` | Your own explosions hurt you, at half damage. |
| `respawnSeconds` | number | `3` | Seconds before a dead combatant comes back. `0` means never (rounds, survival). |
| `spawnPoints` | `ResolvedPoint[]` | `level.spawns` | Respawn uses the point whose nearest living enemy is farthest away. |
| `noise` | `NoiseBus` | none | Pass `squad.noise` so bots hear shots and explosions. |
| `aimAssist` | `AimAssistConfig` or `false` | `{}` (on, defaults above) | Aim assist for `aimFromView`. |
| `seed` | number | `11` | Seed for spread and aim error. |
### Combatants
A combatant is a body you already have: the player's `CharacterState`, or a bot's `bot.state`. The world reads and pushes that body; you keep moving it with `stepCharacter` or the squad.
```ts
world.add({ id: 'player', name: 'You', color: '#7C5CFF', team: null, body, params, weapon: 'rifle', health: { max: 100, regenPerSecond: 12 } });
world.add({ id: bot.id, name: bot.name, color: bot.color, body: bot.state, params: bot.params, weapon: 'smg', bot: true });
```
| `CombatantSpec` field | Default | What it does |
|---|---|---|
| `id` | required | Unique. Adding the same id again replaces it. |
| `name` | the id | Shown in the killfeed. |
| `team` | `null` | Same string is the same side. `null` is free-for-all. |
| `color` | `'#FFFFFF'` | Killfeed colour. |
| `health` | defaults above | A `HealthConfig`. |
| `weapon` | `'rifle'` | A preset name or a `WeaponConfig`. |
| `body` | required | The `CharacterState` this combatant is. |
| `params` | required | Its `CharacterParams` (radius and height set the hit capsule). |
| `bot` | `false` | Marks a bot. |
A `Combatant` adds fields you read for the HUD and the score: `health` (`hp`, `max`, `armor`, `dead`), `weapon`, `weaponState` (`ammo`), `statuses`, `effects`, `kills`, `deaths`, `assists`, `streak`, `bestStreak`, `damageDealt`, `shotsFired`, `shotsHit`, `respawnAt`, `lastHitAt`, `lastKillAt`, `lastHurtAt`. An assist is damage dealt within 6 seconds before someone else's kill. A self-kill costs one kill.
### Methods
| Method | What it does |
|---|---|
| `add(spec)`, `remove(id)`, `get(id)` | Manage combatants. `get(null)` returns `null`. |
| `equip(id, weapon, overrides?)` | Swap a weapon (pickups, loadouts). The magazine is full. |
| `hostile(a, b)` | Whether two combatants are enemies. |
| `bodies()` | Hit capsules of the living, positions refreshed. |
| `targetsFor(id)` | Living enemies of `id`, shaped as `BotTarget[]` for `squad.step`. |
| `muzzleOf(combatant, dir, out?)` | Where shots leave: chest height, a little in front of the body. |
| `aimFromView(id, viewOrigin, viewDir, out)` | Turns a camera ray into a shot direction from the muzzle, with aim assist. What is under the crosshair is what gets hit, also in third person. Returns `{ id, angleDeg }` of the locked target. |
| `pullTrigger(id, held, pressed, dir, now, dt?)` | The fire button. Returns the shots fired. Stunned combatants cannot fire. |
| `reload(id, now)` | Starts a reload. |
| `damage(targetId, amount, by, now, options?)` | Hurts a combatant from game code: lava, traps, a punch you resolve yourself. `by` `null` is the world. Options: `headshot`, `point`, `push`, `knockback`, `weapon` (name in the killfeed), `status`, `quiet` (no damage number). Returns the damage dealt. |
| `step(dt, now)` | Projectiles, burns, regeneration, falls below `level.killY`, respawns, fading effects. Once per frame. |
| `respawn(combatant, now, at?)` | Brings a combatant back now, at the safest spawn or at `at`. |
| `safestSpawn(combatant)` | The spawn point farthest from living enemies. |
| `drainEvents()` | Events since the last call. |
| `scoreboard()` | Combatants sorted by kills, then fewest deaths, then assists. |
| `teamScores()` | Kills per team. Free-for-all players count under their own id. |
| `reset(now)` | Clears projectiles, effects, the feed and pending events for a new round. |
Lists to draw from, refreshed by `step`: `feed` (`FeedEntry[]`, the last 5 kills for 6 seconds), `tracers` (`Tracer[]`), `impacts` (`Impact[]`, sparks and explosions), `numbers` (`DamageNumber[]`), `projectiles`. Also `combatants`, `level`, `rng` and `now`.
### Events
`drainEvents()` returns `CombatEvent[]`. Use them for sounds, haptics and scoring.
| `type` | Fields |
|---|---|
| `'shot'` | `by`, `weapon`, `at` |
| `'hit'` | `by`, `target`, `damage`, `headshot`, `killed`, `x`, `y`, `z`, `at` |
| `'kill'` | `by`, `target`, `weapon`, `headshot`, `streak`, `at` |
| `'explode'` | `by`, `x`, `y`, `z`, `radius`, `at` |
| `'reload'` | `id`, `at` |
| `'respawn'` | `id`, `at` |
## Bots that shoot
The bot's brain (see [Bots](https://docs.moonspell.app/engine/bots.md)) decides where to go. The gunner decides when to shoot and how badly to aim. A gunner fires at the bot's current target while the bot sees it, is in `chase` or `strafe`, and the target is in range. It fires in bursts with pauses and aim error, so bots feel like players and not turrets. A bot that gets shot turns toward whoever shot it.
```ts
import { createGunner, driveBotWeapon } from '@/engine';
const gunner = createGunner('normal', { fleeBelow: 0.25 });
// every frame, after squad.step:
driveBotWeapon(world, bot, gunner, now, dt);
```
| `GunnerConfig` field | easy | normal | hard | Range |
|---|---|---|---|---|
| `aimErrorDeg` (full cone) | 7 | 4 | 2 | 0 to 20 |
| `burstSeconds` | 0.5 | 0.8 | 1.1 | 0.1 to 4 |
| `pauseSeconds` | 1.2 | 0.75 | 0.45 | 0 to 4 |
| `rangeFraction` (of weapon range) | 0.55 | 0.75 | 0.9 | 0.1 to 1 |
| `lead` | `true` | `true` | `true` | Projectile weapons aim ahead of moving targets. |
| `fleeBelow` | `0` | `0` | `0` | Health fraction under which the gunner sets `bot.scared`, so the bot runs. 0 to 1. |
Exports: `createGunner(difficulty?, config?)` returns a `Gunner` (its `firing` field is true on frames it held the trigger); `resolveGunner(difficulty?, config?)` returns the `GunnerProfile`; `driveBotWeapon(world, bot, gunner, now, dt)` returns the shots fired. The combat bot's id must match its squad bot's id.
Roles that fit: `skirmisher` with rifles or blasters (keeps its range and circles), `hunter` with shotguns or swords (closes in), `guard` with a sniper on patrol.
## CombatEffects
`` draws a `CombatWorld` inside the `EngineCanvas`: tracers for hitscan shots, glowing bolts for projectiles, sparks and explosion balls. It uses fixed pools of meshes moved every frame (24 tracers, 48 bolts, 16 impacts), so a firefight never allocates or re-renders React.
| Prop | Type | What it does |
|---|---|---|
| `world` | `CombatWorld` | Required. |
| `now` | `() => number` | Required. Returns the same game clock the world runs on. |
| `numbersOut` | `ScreenNumber[]` | Optional. Filled every frame with damage numbers projected to screen points, for ``. |
## CombatHud
HUD pieces as React Native views, placed outside the canvas. Feed them from a snapshot you update a few times a second. `DamageNumbers` is the exception: it polls the array `CombatEffects` fills.
| Component | Props | What it shows |
|---|---|---|
| `Crosshair` | `spreadDeg` (1), `locked` (false), `color`, `lockColor`, `hidden` (false) | Centre crosshair that grows with spread and turns `lockColor` when aim assist has a target. |
| `HitMarker` | `hitAt`, `killAt`, `now` (all required) | An X for 0.15 s after a hit, larger and red for 0.35 s after a kill. Pass the combatant's `lastHitAt` and `lastKillAt`. |
| `HealthBar` | `hp`, `max` (required), `armor` (0), `maxArmor` (0), `accent` | Health bar and number. Shows an armour bar when `maxArmor` is above 0. Turns red under 30%. |
| `AmmoCounter` | `ammo`, `magazine`, `reloading` (required), `weapon` | `ammo / magazine`, `RELOADING`, or an infinity sign when the magazine is 0. |
| `Killfeed` | `entries` (`FeedEntry[]`) | The last eliminations. Pass a copy of `world.feed`. |
| `DamageNumbers` | `source` (`ScreenNumber[]`), `onlyBy` | Floating numbers. `onlyBy="player"` shows only the player's hits. |
Colours default to the template's theme (`@/theme`). Every piece has a `testID` (`hud-crosshair`, `hud-hit-marker`, `hud-kill-marker`, `hud-health`, `hud-ammo`, `hud-killfeed`) for device tests.
Pulse Arena marks its FIRE button `look: true`, so the finger that holds it also turns the camera: hold FIRE and drag to aim with the same thumb. FIRE stays held until that finger lifts, wherever it slides (see [Input](https://docs.moonspell.app/engine/input.md)).
## Gotchas
- Read `input.held.fire` and `input.pressed.fire` before `consumeInput`. It clears this frame's presses, and semi-automatic weapons only fire on a press.
- Pass one game clock to everything: `pullTrigger`, `step`, `driveBotWeapon`, and the `now` prop of `CombatEffects`. Mixing clocks breaks fire rates, regeneration and fading effects.
- `CombatWorld` never draws or moves bodies on its own beyond knockback and respawn. Hide the player's mesh while `health.dead`; `BotMeshes` hides bots with `disabled` set, so set `bot.disabled` from the world each frame.
- Bots and combatants are linked only by id. Use the same id in `squad.add` and `world.add`.
- Falling below `level.killY` kills a combatant in `step`. The kill shows as `'fall'` in the feed.
- A dead player still has a body. Stop moving it: `applyEffectsToCommand` zeroes the command of a dead combatant.
- Projectiles are capped at 96 alive and `CombatEffects` draws 48 bolts. Keep about 6 combatants with hitscan weapons for a steady frame on a phone.
- Tuning that plays well on a phone: a time to kill of 0.8 to 1.5 seconds with the rifle on 100 hp, aim assist cone 6 to 10 degrees with strength 0.5 to 0.6, bots at `normal`. Lower damage beats lower aim assist.
- `CombatWorld` runs on one device. Online, one phone (the host) runs it and the others send their aim and trigger; the [shooter module](https://docs.moonspell.app/modules/shooter.md) does this for you. Leaderboards for kills or times need the game's backend: see [Backend overview](https://docs.moonspell.app/backend/overview.md) and [Networking](https://docs.moonspell.app/engine/net.md).
## See also
- [Examples](https://docs.moonspell.app/examples.md): **Pulse Arena** (`examples/pulse-arena/`) composes the whole kit: rifle, shotgun and rockets, aim assist, health with regeneration, armed bots that strafe and flee, killfeed, respawns, first to 10. **Flag Rush** (`examples/flag-rush/`) is capture the flag on the shooter module.
- [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md): the level, the controller and the camera rig the kit runs on.
- [Bots](https://docs.moonspell.app/engine/bots.md): the brains that move the bots this kit arms.
- [Shooter module](https://docs.moonspell.app/modules/shooter.md): ready match modes built on this kit.
- [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): its sorties use their own simpler fight rules, not `CombatWorld`.
- [Input](https://docs.moonspell.app/engine/input.md): `CharacterControls` and its buttons.
---
# Bots
Source: https://docs.moonspell.app/engine/bots
> Computer opponents that patrol, hear, see, chase, keep their distance or flee on the same character controller as the player, with a nav grid, steering, senses and a roster that fills empty seats.
The bots kit (`src/engine/bots/`) gives a 3D character game opponents: guards that patrol and investigate noises, hunters that chase you down, skirmishers that keep their distance and circle, cowards that run. Use it for tag, stealth, horror, arena fights, or to fill empty seats in an online room.
Bots move with the same character controller as the player (`stepCharacter`, see [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)), with the player's params scaled by their profile. They obey walls, gravity and collisions exactly like the player, so they are fair. Everything here is plain TypeScript except ``. To make bots shoot, add the [combat kit](https://docs.moonspell.app/engine/combat.md)'s gunners.
## Smallest working example
Two guards patrol the arena. They hear you sprint and land, see you when you cross their view, chase you, and back off after a tag. This is `examples/lantern-tag/` cut down to the bot parts.
```tsx
import React, { useMemo, useRef } from 'react';
import { StyleSheet, View } from 'react-native';
import { useFrame } from '@react-three/fiber';
import {
BotMeshes,
BotSquad,
CharacterCamera,
CharacterControls,
CharacterMesh,
EngineCanvas,
LevelMeshes,
TopBar,
WORLD_FRAME,
backOff,
consumeInput,
createCharacterInput,
createCharacterState,
createCommand,
createRoster,
moveFrameFor,
resolveCameraRig,
resolveCharacterParams,
resolveLevel,
stepCharacter,
type CharacterHandle,
type CharacterInput,
type CharacterState,
type GameDefinition,
type GameScreenProps,
type Vec2,
} from '@/engine';
const LEVEL = resolveLevel({ preset: 'arena', seed: 4 });
const PARAMS = resolveCharacterParams({ runSpeed: 6.5, walkSpeed: 3 });
const PATROLS: Vec2[][] = [
[{ x: -16, z: -14 }, { x: -16, z: 14 }, { x: -5, z: 14 }],
[{ x: 16, z: 14 }, { x: 16, z: -14 }, { x: 5, z: -14 }],
];
const makeSquad = () => {
const squad = new BotSquad({ level: LEVEL, params: PARAMS, seed: 9 });
// One human seat (you); the roster names and colours the bots that fill the rest.
const roster = createRoster({ targetPlayers: 3, maxBots: 2 });
roster.reconcile(1);
roster.bots.forEach((b, i) => {
const route = PATROLS[i % PATROLS.length];
squad.add({ id: b.id, name: b.name, color: b.color, role: 'guard', difficulty: 'normal', spawn: { x: route[0].x, z: route[0].z }, patrol: route });
});
return squad;
};
const Simulation = ({ input, body, squad }: { input: CharacterInput; body: { current: CharacterState }; squad: BotSquad }) => {
const rig = useMemo(() => resolveCameraRig('third'), []);
const mesh = useRef(null);
const cmd = useRef(createCommand());
const frame = useRef({ ...WORLD_FRAME });
const clock = useRef(0);
const footstepAt = useRef(0);
const target = useRef({ id: 'player', x: 0, y: 0, z: 0 });
useFrame((_, rawDt) => {
const dt = Math.min(rawDt, 1 / 30);
clock.current += dt;
const now = clock.current;
const s = body.current;
consumeInput(input, cmd.current);
stepCharacter(s, cmd.current, dt, LEVEL, PARAMS, moveFrameFor(rig, s, frame.current));
// Noises the guards can hear. `by: 'player'` lets bots ignore their own.
if (s.landed) squad.noise.post({ x: s.x, z: s.z, loudness: 0.6, at: now, by: 'player' });
if (s.grounded && s.speed > PARAMS.walkSpeed + 0.5 && now - footstepAt.current > 0.35) {
footstepAt.current = now;
squad.noise.post({ x: s.x, z: s.z, loudness: 0.35, at: now, by: 'player' });
}
// Bots think and move after the player.
const t = target.current;
t.x = s.x;
t.y = s.y;
t.z = s.z;
squad.step(dt, now, [t]);
// A tag: the bot backs off for 3 seconds.
for (const bot of squad.bots) {
const b = bot.state;
if (Math.hypot(b.x - s.x, b.z - s.z) < PARAMS.radius * 2 + 0.15 && now > bot.backoffUntil) backOff(bot, now, 3);
}
if (mesh.current?.group) {
mesh.current.group.position.set(s.x, s.y, s.z);
mesh.current.group.rotation.y = s.yaw;
}
});
return (
<>
>
);
};
const Tag = ({ onExit }: GameScreenProps) => {
const input = useMemo(() => createCharacterInput(), []);
const body = useRef(createCharacterState({ x: 0, y: 0, z: -19, yaw: 0 }));
const squad = useMemo(() => makeSquad(), []);
return (
);
};
const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' } });
const game: GameDefinition = { id: 'tag', title: 'Tag', description: 'Two guards patrol the arena. They hear you run.', Component: Tag };
export default game;
```
To run it, put it in `src/game/index.tsx` and start the game as in the [quickstart](https://docs.moonspell.app/start/quickstart.md). The full version with lanterns to collect, lives, a "SPOTTED" banner, a best time and a dev autopilot is `examples/lantern-tag/index.tsx`.
## BotSquad
`BotSquad` owns the bots of one game. It builds the shared nav grid from the level, keeps the shared noise bus, and each frame runs every bot's brain and then `stepCharacter` for its body.
```ts
const squad = new BotSquad({ level, params });
squad.add({ id: 'g1', role: 'guard', spawn: { x: 0, z: 10 } });
// each frame, after the player moved:
const summary = squad.step(dt, now, [{ id: 'player', x, y, z }], running);
```
| `BotSquadOptions` field | Default | What it does |
|---|---|---|
| `level` | required | The level from `resolveLevel`. |
| `params` | required | The player's `CharacterParams`. Each bot scales them by its profile. |
| `grid` | built from the level | A prebuilt `NavGrid`. |
| `gridOptions` | radius, height and step height from `params` | Options for `buildNavGrid`. See [Nav grid](https://docs.moonspell.app/engine/bots.md#nav-grid). |
| `noise` | a new `NoiseBus` | Share one bus with a `CombatWorld` so bots hear shots. |
| `seed` | `3` | Seed for wandering and flee points. |
| Member | What it does |
|---|---|
| `add(spec)` | Creates a bot from a `BotSpec` and returns it. |
| `remove(id)`, `get(id)`, `clear()` | Manage bots. |
| `step(dt, now, targets, running?)` | Advances every bot. Returns the `SquadSummary`. |
| `bots`, `grid`, `noise`, `level` | The bots, the nav grid, the noise bus, the level. |
| `summary` | `{ bots, seeing, chasing, path }`: active bots, how many see a target, how many chase or strafe, the longest current path in points. Use it for HUD hints ("SPOTTED" when `chasing` is above 0). |
`targets` is a `BotTarget[]` (`{ id, x, y, z }`), usually the player. Pass a function `(bot) => BotTarget[]` to give each bot its own list; for free-for-all with combat, `(bot) => world.targetsFor(bot.id)`. A bot never targets itself, and a target that leaves the list is forgotten. `running` `false` keeps bots still (a countdown); they still fall and settle.
Set `bot.disabled = true` for a bot that is out (dead, benched). The squad skips it and `BotMeshes` hides it.
## Bot specs, roles and difficulty
| `BotSpec` field | Default | What it does |
|---|---|---|
| `id` | required | Unique. Take it from the roster. |
| `name` | the id | Display name. |
| `color` | `'#FF5C7A'` | Mesh colour. |
| `role` | `'guard'` | `'guard'`, `'hunter'`, `'skirmisher'` or `'coward'`. |
| `difficulty` | `'normal'` | `'easy'`, `'normal'` or `'hard'`. |
| `overrides` | none | Any profile field below, per bot. |
| `spawn` | required | `{ x, y?, z, yaw? }`. |
| `patrol` | `[]` | A loop of `{ x, z }` points. Empty means wander between random reachable points. |
Roles:
- `guard` patrols, investigates noises, chases what it sees, and gives up when it loses you for `memorySeconds`.
- `hunter` does the same but remembers longer (14 s), hears farther (20 m) and patrols faster.
- `skirmisher` keeps `preferredRange` (7 m) from its target and circles it. Pair it with a gun.
- `coward` runs when a target comes within `fleeDistance` (9 m). Prey, panicked civilians, hide and seek.
The profile comes from the difficulty, then the role, then `overrides`. Every field is clamped.
| `BotProfileConfig` field | easy / normal / hard | Range | What it does |
|---|---|---|---|
| `reactionMs` | 900 / 450 / 150 | 0 to 3000 | How long a target must stay in view before the bot reacts. |
| `sightRange` | 10 / 16 / 24 | 3 to 60 | Metres. |
| `fovDeg` | 100 / 120 / 150 | 30 to 360 | Full field of view. |
| `hearingRange` | 8 / 14 / 22 (hunter 20) | 0 to 60 | Metres a loudness-1 noise carries. |
| `memorySeconds` | 3 / 6 / 10 (hunter 14) | 0 to 30 | How long a lost target is remembered. |
| `speed` | 0.8 / 0.95 / 1 (coward 1) | 0.3 to 1.5 | Top speed, times the player's run speed. |
| `patrolSpeed` | 0.9 (hunter 1.1) | 0.3 to 1.5 | Patrol speed, times the player's walk speed. |
| `preferredRange` | 6 (skirmisher 7) | 2 to 20 | Metres a skirmisher keeps. |
| `fleeDistance` | 6 (coward 9) | 1 to 30 | Metres at which a coward runs. |
| `pauseSeconds` | 1.2 | 0 to 10 | Wait at a patrol point or an investigated noise. |
| `repathMs` | 400 | 100 to 2000 | Time between path recomputations. |
`BOT_ROLES` and `BOT_DIFFICULTIES` list the allowed values. `resolveBotProfile(role?, difficulty?, overrides?)` returns a full `BotProfile`; `botParamsFrom(playerParams, profile)` returns the `CharacterParams` a bot runs with.
## Brain and behaviours
`stepBot(bot, world, dt, cmd)` is one frame of a bot's mind: senses, then a decision, then steering. It writes a world-frame stick and sprint into `cmd`; the caller runs `stepCharacter` with `bot.params` and `WORLD_FRAME`. `BotSquad.step` does this for you. Use `stepBot` and `createBot(spec, playerParams?)` directly only when you manage bots yourself; you then build the `BotWorld` (`level`, `grid`, `noise`, `now`, `targets`, `others`, `rng`).
`bot.behaviour` is one of:
| Behaviour | When |
|---|---|
| `'idle'` | Before the first step. |
| `'patrol'` | Walking the patrol loop or wandering. |
| `'investigate'` | Heard a noise or lost a target: goes to the spot, waits, returns to patrol. |
| `'chase'` | Saw a target for `reactionMs`. Sprints along a path, re-pathing every `repathMs`. |
| `'strafe'` | A skirmisher that sees its target inside 1.3 times `preferredRange`: circles it, facing it. |
| `'flee'` | A coward near a target, a bot with `scared` set, or a bot backing off. Runs toward the reachable point farthest from the threat. |
| `'objective'` | `bot.objective` is set and no enemy is in mind (or `objectiveRush` is on). |
| `'return'` | Walking back to patrol. |
Fields the game sets on a `Bot`:
| Field | Default | What it does |
|---|---|---|
| `disabled` | `false` | The bot is out. The squad skips it, `BotMeshes` hides it. |
| `scared` | `false` | Any role flees. The combat kit's gunner sets it below `fleeBelow` health. |
| `objective` | `null` | A planar point to go to and hold (a flag, a hill, a base). |
| `objectiveRadius` | `1.5` | Metres from the objective that count as there. |
| `objectiveRush` | `false` | Ignore enemies and keep running to the objective (a flag carrier). |
`backOff(bot, now, seconds?)` makes a bot retreat from its target for a while, default 2.5 seconds. Call it after a tag or a hit.
Fields you read: `behaviour`, `seesTarget` (true on frames the bot sees a target), `targetId`, `lastKnown`, `path`, `state` (its `CharacterState`), `params`, `profile`.
## Senses
```ts
import { canSee, NoiseBus } from '@/engine';
```
`canSee(observer, target, level, sight, targetHeight?)` is true when the target is within `sightRange`, inside `fovDeg` of the observer's `yaw`, and nothing in the level blocks the line from the eyes (`eyeHeight`) to the target's chest. `targetHeight` defaults to 1.7. It also works as a line-of-sight check outside bots.
`NoiseBus` holds things that happened loudly. The game posts; each bot asks what it heard since it last listened. A noise carries `hearingRange` times `loudness` metres. The bus keeps 4 seconds of history and at most 48 noises.
| Member | What it does |
|---|---|
| `post({ x, z, loudness, at, by? })` | Records a noise. `loudness` 0 to 1. Set `by` so bots ignore their own. |
| `heardBy(x, z, hearingRange, since, ignoreBy?)` | The newest noise in range made after `since`, or `null`. |
| `prune(now)`, `clear()`, `count` | Housekeeping. `BotSquad.step` prunes for you. |
Loudness that plays well: running footsteps 0.35 every 0.35 s, a landing 0.6, a thrown object 0.8, a shot 1. `CombatWorld` posts shots (the weapon's `loudness`) and explosions (1) when you give it the squad's bus.
## Nav grid
The nav grid is a walkable grid over the level, built from its boxes. Cells are walkable where a body of the agent's radius can stand: the floor, platform tops, ramps and stairs. Neighbouring cells connect when their surfaces are within a step. Paths are A* over 8 neighbours, with no corner cutting, then string-pulled.
```ts
import { buildNavGrid, findPath, nearestWalkable } from '@/engine';
const grid = buildNavGrid(level, { cellSize: 1, agentRadius: params.radius });
const path = findPath(grid, { x: 0, z: -10 }, { x: 8, z: 12 }); // Vec2[] or null
```
| `NavGridOptions` field | Range | Default | What it does |
|---|---|---|---|
| `cellSize` | 0.5 to 4 | `1` | Metres per cell. |
| `agentRadius` | metres | `0.4` | Obstacles grow by this. |
| `agentHeight` | metres | `1.7` | Boxes higher than this above the surface are walked under. |
| `stepHeight` | metres | `0.35` | Boxes lower than this are stepped onto, not around. |
| `groundY` | metres | the level's floor (0 when there is none) | Ground height. |
| Function | What it does |
|---|---|
| `buildNavGrid(level, options?)` | Builds the `NavGrid`. |
| `findPath(grid, from, to, { smooth? })` | World points from `from` to `to`, or `null` when unreachable. Ends inside an obstacle snap to the nearest walkable cell. `smooth` defaults to true. |
| `smoothPath(grid, path)` | Drops points a straight line can skip. |
| `nearestWalkable(grid, x, z, maxRings?)` | Nearest walkable cell centre, or `null`. `maxRings` defaults to 12. |
| `randomReachable(grid, from, rng, minDist?)` | A random reachable cell centre, at least `minDist` (default 4) away when possible. |
| `farthestFrom(grid, from, threat, rng, samples?)` | A reachable point far from `threat` (for fleeing). |
| `gridLineClear(grid, a, b)` | True when every cell under the segment is walkable and steppable. |
| `walkableAt(grid, x, z)`, `cellOf(grid, x, z)`, `centerOf(grid, c, r, out?)`, `canStep(grid, i, j)` | Low-level cell queries. |
The grid works for your own code too: the Lantern Tag and Pulse Arena autopilots walk the player along `findPath(squad.grid, ...)`.
## Steering
Steering functions write a planar direction (inside the unit disc) into `out`. Combine them with `addWeighted` and `clampUnit`, then turn the result into a stick with `worldDirToStick` from the [character kit](https://docs.moonspell.app/engine/character-and-cameras.md). The brain uses all of them; you can use them for your own movers.
| Function | What it does |
|---|---|
| `seek(from, to, out)` | Straight toward a point. |
| `arrive(from, to, slowRadius, out, stopRadius?)` | Seek that eases off inside `slowRadius` and stops within `stopRadius` (default 0.25). |
| `flee(from, threat, out)` | Straight away. |
| `orbit(from, target, range, band, clockwise, out)` | Holds `range` from a target and circles it. |
| `separation(from, others, radius, out)` | Pushes away from neighbours closer than `radius`. |
| `wander(state, rng, jitter, out)` | A slowly drifting heading. `state` is a `WanderState` (`{ angle }`). |
| `addWeighted(acc, v, weight)`, `clampUnit(v)`, `zero(out)` | Vector helpers. |
## Avoidance
`avoid(self, desired, others, out, options?)` steers around moving neighbours. It tries directions around the one the bot wants, at three paces, and scores each by how far it turns, how soon it would hit a neighbour (each agent is assumed to take half the dodge), and how much it turns from the current heading, so a dodge sticks instead of jittering. Ties go to the right, so two bots meeting head-on pass each other. The brain calls it every frame, then adds a gentle separation push.
| `AvoidOptions` field | Default | What it does |
|---|---|---|
| `radius` | `0.4` | Body radius of each agent. |
| `horizon` | `1.5` | Seconds ahead to look for collisions. |
| `speed` | `4` | Agent speed in m/s, to turn the direction into a velocity. |
| `blocked` | none | `(x, z) => boolean`, true where the agent cannot stand. Directions that hit one within half a second are skipped. |
`timeToCollision(px, pz, wx, wz, r)` returns the seconds until two discs touch: `Infinity` if never, `0` if touching.
## Roster
The roster decides how many bots a room needs so it never feels empty, and who they are. Humans always get the seat: when a human joins, the newest bot leaves.
```ts
import { createRoster } from '@/engine';
const roster = createRoster({ targetPlayers: 4, maxBots: 6 });
for (const change of roster.reconcile(humanCount)) {
if (change.type === 'bot-joined') squad.add({ id: change.bot.id, name: change.bot.name, color: change.bot.color, spawn });
else squad.remove(change.bot.id);
}
```
| `RosterOptions` field | Range | Default |
|---|---|---|
| `targetPlayers` | 1 to 16 | `4` |
| `maxBots` | 0 to 12 | `6` |
| `names` | strings | `BOT_NAMES` (Juno, Pax, Vega and 9 more) |
| `colors` | CSS colours | `BOT_COLORS` (8 colours) |
`reconcile(humanCount)` returns `SeatChange[]` (`bot-joined` or `bot-left`, each with a `RosterBot`: `id` like `bot-0`, `name` like `Bot Juno`, `color`, `seat`). `bots` lists the current bots, `reset()` forgets everyone. With an online room, call `reconcile` with the number of non-spectator players whenever the room's player list changes. Without a backend the room has one player, you, so bots fill the rest. See [Networking](https://docs.moonspell.app/engine/net.md).
## BotMeshes
`` draws every bot inside the `EngineCanvas` with the engine's `CharacterMesh`, moved every frame from `bot.state`. A bot glows in `alertColor` while it chases or strafes and turns its head toward what it sees. Disabled bots are hidden.
| Prop | Type | Default | What it does |
|---|---|---|---|
| `bots` | `readonly Bot[]` | required | The squad's bots. Pass a fresh array when the roster changes. |
| `radius` | number | the mesh default | Body radius. Pass `params.radius`. |
| `height` | number | the mesh default | Body height. Pass `params.height`. |
| `alertColor` | CSS colour | `'#FF3B30'` | Glow while hunting. |
For your own bot models, read `bot.state` (`x`, `y`, `z`, `yaw`) and `bot.behaviour` in a `useFrame` the same way.
## Gotchas
- Step the squad after the player moved, every frame, with the same game clock you use for noises.
- Keep `speed` at or below 1 unless the game is meant to be unfair. A bot faster than the player can never be escaped.
- The nav grid is planar per surface. Bots walk ramps and stairs but do not jump between platforms.
- Corridors bots must pass need room for the grown body: leave at least 1.8 m, or build the grid with a smaller `cellSize`.
- Post noises for running and landing. Without them, hearing does nothing and guards only react to sight.
- `BotMeshes` reads the `bots` array you pass. After `squad.add` or `squad.remove`, pass a new array (or re-create the squad) so React sees the change.
- Cost: six bots on the arena preset stay well under a millisecond a frame on a recent iPhone. Keep 8 bots or fewer and `cellSize` at 1 m or more on big levels.
- Bots run on one device. In an online room, run them on one phone (the host) and send their poses with the room state; the [shooter module](https://docs.moonspell.app/modules/shooter.md) does this. Rooms need the game's backend: see [Backend overview](https://docs.moonspell.app/backend/overview.md).
## See also
- [Examples](https://docs.moonspell.app/examples.md): **Lantern Tag** (`examples/lantern-tag/`) is guards with patrols, hearing, sight, chases and back-offs, plus a roster and a nav-grid autopilot. **Pulse Arena** (`examples/pulse-arena/`) arms skirmisher and hunter bots with the combat kit. **Flag Rush** (`examples/flag-rush/`) shows bot objectives in capture the flag through the shooter module.
- [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md): the controller and the level bots move through.
- [Combat](https://docs.moonspell.app/engine/combat.md): gunners that make bots shoot.
- [Shooter module](https://docs.moonspell.app/modules/shooter.md): sets `bot.objective` for flags and hills, and fills seats with the roster.
- [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): its sorties use their own enemy logic, not `BotSquad`.
---
# Saves and progression
Source: https://docs.moonspell.app/engine/saves
> Typed, versioned saves kept on the device and synced to the game's backend when it has one, plus currencies, a shop, unlocks, XP, daily streaks, offline earnings, player settings and a shareable score card.
Everything a game keeps between sessions goes through `src/engine/save`: levels reached, coins, skins, upgrades, a high score, a tutorial already shown. A save lives on the device first, so the game opens instantly and plays offline. When the game has a backend, the same save syncs to it by itself and shows up on the player's other devices. Nothing in your game code changes between the two.
Use it whenever a game remembers anything. Do not write your own keys to MMKV, AsyncStorage or `localStorage`: an engine save survives game updates (versions and migrations), merges two devices, and never throws data away on a bad read.
The module has four parts:
| Part | Use it for |
| --- | --- |
| `defineSave` | The game's own state with a typed shape: levels reached, story flags, a built base. |
| `defineProgression` | One save with an economy: currencies, a shop, unlocks and items, XP and levels, a daily streak, offline earnings, bests and stats, plus your own fields. |
| Settings (`useSettings`, `firstTime`) | Sound, music, vibration, volume, left-handed controls, sensitivity, reduced motion, one-time tips. Kept on the device, never synced. |
| `ScoreCard` and `shareScore` | A best-score card shared as an image. |
Everything below is exported from `@/engine`.
## Smallest working example
A save with two fields, a button that changes it, and a screen that re-renders when it changes.
```tsx
import React from 'react';
import { Pressable, Text, View } from 'react-native';
import { defineSave, useSave, type GameDefinition, type GameScreenProps } from '@/engine';
// Define each save once, at module scope.
const save = defineSave({
version: 1,
defaults: { coins: 0, bestRun: 0 },
});
const Game = ({ onExit }: GameScreenProps) => {
const data = useSave(save); // re-renders on every change, local or synced
return (
Coins: {data.coins}
save.update((s) => { s.coins += 10; })}>
Earn 10
Exit
);
};
const game: GameDefinition = {
id: 'coin-tap',
title: 'Coin Tap',
description: 'Tap to earn coins. They are still there next time.',
Component: Game,
};
export default game;
```
Close the game and open it again: the coins are still there.
## defineSave
```ts
import { defineSave, useSave } from '@/engine';
export const save = defineSave({
version: 2,
defaults: { coins: 0, skins: ['classic'], bestRun: 0, tutorialDone: false },
migrations: {
// Version 1 kept skins as a comma string. Each step gets a copy of the old data.
2: (old) => ({ ...old, skins: String(old.skins ?? 'classic').split(',') }),
},
});
save.get().coins; // read anywhere, outside React too
save.update((s) => { s.coins += 10; }); // change a copy in place, or return a new value
save.set({ ...save.get(), bestRun: 1800 });
save.reset(); // start over, on every device
const data = useSave(save); // in a component
```
`defineSave` returns the save's main slot, a `SaveSlot`. `Save` is the type name for it.
### Changing the shape
- **Adding a field needs no migration.** Put it in `defaults`. Old saves get it the next time they load. This works recursively inside nested objects.
- **Renaming, retyping or splitting a field needs one.** Bump `version` and add `migrations[newVersion]`. Migrations run in order, from the version the save was written with up to the current one, each on a copy.
- A field whose type no longer matches the default (a number that became an object) takes the default.
- Fields the defaults do not know are kept, so a newer save opened by an older build loses nothing.
### What is never lost
- A save that cannot be read is kept aside under the storage key `..corrupt`, and the game starts from the defaults.
- A migration that throws keeps the old save under `..v` and carries on with what it has.
- When two devices changed the same slot and you gave no `merge`, the copy changed last wins and the other is kept under `..backup`.
The default `key` is `save`, so the main slot is stored as `save.main`.
### SaveSpec options
| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `version` | `number` | required | The shape's version, a whole number from 1. Bump it when old data needs converting. |
| `defaults` | `T` | required | A new player's save. New fields here reach existing saves by themselves. |
| `migrations` | `Record unknown>` | none | `migrations[n]` turns a version `n - 1` save into version `n`. |
| `legacy` | `(store) => unknown` | none | Reads what the game kept before it used saves (its own old keys), the first time only, into the main slot. |
| `sanitize` | `(data: T) => T` | none | Runs after every load and change: clamp numbers, drop items that no longer exist. |
| `merge` | `(local: T, remote: T) => T` | last change wins | Combines two copies that changed the same slot. |
| `key` | `string` | `'save'` | Storage key prefix. Two saves in one game need different keys; the key keeps them apart on the device and on the backend (see [Sync](https://docs.moonspell.app/engine/saves.md#sync)). |
| `sync` | `boolean` or `string` | `true` | `true` syncs when `GAME_BACKEND_URL` is set. `false` keeps the save on this device. A string is a backend URL to use instead. |
| `store` | `KeyValue` | the game's on-device store | Where to keep it. Tests pass `memoryStore()`. |
| `transport`, `debounceMs`, `live` | | | Sync wiring for tests (see [Testing](https://docs.moonspell.app/engine/saves.md#testing)). |
### SaveSlot methods
| Method | Returns | What it does |
| --- | --- | --- |
| `get()` | `T` | The current data. Treat it as read-only. |
| `update(fn)` | `T` | `fn` gets a copy to change in place, or returns a new value. Written to the device at once, sent to the backend a moment later. |
| `set(data)` | `T` | Replaces the data. |
| `reset()` | `T` | Back to the defaults on every device. Other devices take the reset instead of merging it away. |
| `subscribe(listener)` | unsubscribe function | Called with the data after every change: local, pulled or merged. |
| `slot(id)` | `SaveSlot` | Another slot of the same save. Ids are 1 to 32 lowercase letters, digits, `-` or `_`. |
| `slots()` | `string[]` | Slot ids with data on this device or on the server. |
| `exists()` | `boolean` | Whether anything was ever saved in this slot, for a slot picker. |
| `meta()` | `SaveMeta` | `{ slot, version, at, dirty, synced }`. |
| `syncStatus()` | `SyncStatus` or `'local'` | See [Sync](https://docs.moonspell.app/engine/saves.md#sync). |
| `onSyncStatus(listener)` | unsubscribe function | Called when the sync status changes. |
| `syncNow()` | `Promise` | Sends and fetches now, for example before showing a "synced" mark. |
Most games need only the main slot. Use `save.slot('2')` for a second save file.
## defineProgression
One save that holds a whole economy. Define it once, for example in `src/game/progress.ts`, and import it wherever you need it.
```ts
import { defineProgression, useProgression } from '@/engine';
export const progress = defineProgression({
currencies: { coins: 0, gems: 0 },
caps: { gems: 9999 },
shop: [
{ id: 'neon', name: 'Neon board', price: { coins: 500 } }, // unlock, bought once
{ id: 'magnet', kind: 'upgrade', price: { coins: 100 }, maxLevel: 5, growth: 1.8 }, // levels, dearer each time
{ id: 'revive', kind: 'consumable', price: { gems: 1 } }, // into the inventory
{ id: 'coin-pack', kind: 'bundle', price: { gems: 5 }, gives: { coins: 1000 } }, // currency exchange
{ id: 'night-city', price: { coins: 2000 }, requires: { level: 10 } },
],
xp: { base: 100, growth: 1.25, maxLevel: 50 },
levelRewards: (level) => (level % 5 === 0 ? { gems: 5 } : { coins: 50 * level }),
daily: { rewards: [{ coins: 50 }, { coins: 75 }, { coins: 100 }, { gems: 2 }, { coins: 150 }, { coins: 200 }, { gems: 5 }] },
offline: { currency: 'coins', perSecond: (p) => 0.5 + (p.upgrades.magnet ?? 0) * 0.5, capHours: 8 },
records: { distance: 'desc', time: 'asc' },
starting: { unlocked: ['classic'], equipped: { board: 'classic' } },
game: { tutorialDone: false, worldsSeen: [] as string[] },
});
progress.earn('coins', 25);
const result = progress.buy('magnet'); // { ok: true, ... } or { ok: false, reason: 'poor', missing }
progress.addXp(40); // { gained, level, rewards }
```
In a component, `useProgression(progress)` returns the data and re-renders on change. It also marks the player as present when the app goes to the background and when the screen unmounts, so offline earnings count from when the player really left.
### ProgressionSpec options
| Option | Default | What it does |
| --- | --- | --- |
| `currencies` | `{ coins: 0 }` | Starting balances. Also the list of currencies. |
| `caps` | none | Highest balance per currency. |
| `shop` | `[]` | `ShopItem[]`, see below. |
| `xp` | `{}` | The XP curve, see below. |
| `levelRewards` | none | What reaching a level pays: a table (`{ 5: { gems: 1 } }`) or a function of the level. |
| `daily` | none | `{ rewards: Price[] }`, the daily calendar (day 1, day 2 and so on), cycling. |
| `offline` | none | `{ currency, perSecond(p), capHours?, minSeconds? }`: income while away. `capHours` defaults to 8, `minSeconds` to 60. |
| `records` | every key `'desc'` | Which bests count lower as better: `{ time: 'asc' }`. |
| `starting` | none | A new player's `unlocked`, `equipped` and `items`. |
| `game` | `{}` | Your own fields and their defaults, kept in `p.game`. |
| `version`, `migrations` | `1`, none | Bump with a migration when your own fields change shape. |
| `legacy` | none | Reads old progress once. Return a partial `Progress`. |
| `mergeGame` | the copy played last | Combines your own fields from two devices. |
| `key` | `'progress'` | Storage key prefix. |
| `sync`, `store`, `transport`, `debounceMs`, `live` | | Same as `defineSave`. |
### Shop items
| Field | Default | What it does |
| --- | --- | --- |
| `id` | required | The item's id. |
| `name` | the id | Shown in the shop. |
| `price` | required | A `Price` such as `{ coins: 100 }`. For an upgrade, the first level's price. |
| `kind` | `'unlock'` | `unlock`: bought once (a skin, a character, a world). `upgrade`: bought level by level. `consumable`: bought again and again into the inventory. `bundle`: turns one currency into another. |
| `maxLevel` | `10` | Upgrade levels. |
| `growth` | `1.6` | Each upgrade level costs this many times the last. |
| `gives` | `1` | Consumable: how many one purchase adds. Bundle: the currencies it adds, as a `Price`. |
| `requires` | none | `{ level?, unlocked? }`: only for sale from that player level, or once those ids are unlocked. |
### Progression methods
| Method | What it does |
| --- | --- |
| `balance(c)`, `earn(c, n)`, `earnAll(price)` | Read and add to the wallet. Fractions are kept (idle income); show them with `Math.floor`. |
| `canAfford(price)`, `spend(price)` | `spend` returns `false` and takes nothing when the player cannot pay. |
| `buy(id)` | Pays and hands the item over. Returns `{ ok: true, state, price }` or `{ ok: false, reason, price?, missing? }`. |
| `checkBuy(id)` | The same answer without buying, for a shop screen that says why. |
| `priceOf(id)` | The next purchase's price (an upgrade's next level). |
| `upgradeLevel(id)` | An upgrade's current level, 0 when never bought. |
| `isUnlocked(id)`, `unlock(id)` | Things owned for good. |
| `equip(slot, id)`, `equipped(slot)` | Puts an unlocked id in a slot. `equip` returns `false` for an id that is not unlocked. |
| `itemCount(id)`, `addItem(id, n?)`, `consumeItem(id, n?)` | Counted things. `consumeItem` returns `false` when there are not enough. |
| `openLoot(table, rng?, rolls?)` | Rolls a weighted loot table and hands over the drops. |
| `addXp(n)` | Levels up as many times as it reaches and pays level rewards. Returns `{ gained, level, rewards }`. |
| `checkIn()` | Call when the game opens. Returns `{ status, streak, reward }`. |
| `canClaimDaily()`, `claimDaily(multiplier?)` | Pays today's reward once. Pass `2` for a doubled claim. Returns the reward, or `null` if already claimed. |
| `pendingOffline()`, `collectOffline(multiplier?)` | Income while away, as `{ seconds, amount, currency }`. Collecting restarts the clock. |
| `touch()` | Marks the player as present now. `useProgression` calls it for you. |
| `best(key)`, `recordBest(key, value)` | Personal bests. `recordBest` returns `{ improved, previous, best }`. |
| `stat(key)`, `bump(key, by?)` | Lifetime counters: runs played, coins ever earned. |
| `get()`, `update(fn)`, `subscribe(fn)`, `reset()` | Anything else, including `p.game`. |
| `save`, `shop`, `spec` | The underlying `SaveSlot`, the shop list and the spec. |
`buy` fails with one of these reasons:
| `reason` | Meaning |
| --- | --- |
| `unknown` | No shop item has that id. |
| `owned` | An unlock the player already has. |
| `maxed` | An upgrade at `maxLevel`. |
| `locked` | `requires` is not met. |
| `poor` | Not enough currency. `missing` says how much more of each. |
`checkIn()` returns one of four statuses: `started` (first check-in), `continued` (yesterday was checked in, the streak grows), `broken` (a day was missed, the streak restarts at 1) or `same-day`.
Loot tables are arrays of entries, each with an optional `weight` (default 1): `{ item, count? }`, `{ currency, amount }` or `{ nothing: true }`. `count` and `amount` can be a number or a `[min, max]` range.
### The XP curve
| Field | Default | What it does |
| --- | --- | --- |
| `base` | `100` | XP from level 1 to level 2. |
| `growth` | `1.2` | Each level needs this many times the last. |
| `maxLevel` | `99` | Highest level. XP stops counting there. |
| `table` | none | Exact XP per level instead: `table[0]` is level 1 to 2. Levels past its end repeat the last entry. |
`xpToNext(level, curve)` and `levelProgress(state, curve)` (0 to 1, for a progress bar) work on any `{ level, xp }`.
### Two devices
Progression merges field by field, so no unlock, level or best is lost:
- `unlocked` joins both lists.
- `level`, `xp` and `totalXp` come from the copy with more total XP.
- `upgrades`, `stats` and `bests` keep the better value per key.
- The wallet, `items`, `equipped` and `game` come from the copy played last. Pass `mergeGame(local, remote)` to combine your own fields another way.
`mergeProgress(local, remote, spec)` is the same merge as a function.
## Time
`gameNow()` is the server's clock once the game has talked to its backend, and the device's clock before that or without one. Daily streaks and offline earnings use it, so moving the device's date does not farm rewards once a backend exists. A clock moved backwards earns no offline income.
| Function | What it does |
| --- | --- |
| `gameNow()` | Milliseconds since the epoch, server-anchored when known. |
| `hasServerTime()` | Whether `gameNow()` is anchored to the server. |
| `dateKey(date?)` | The local calendar day as `YYYY-MM-DD`. |
| `previousDay(key)`, `daysBetween(from, to)` | Day arithmetic on those keys. |
## Settings
```tsx
import React from 'react';
import { Pressable, Text } from 'react-native';
import { firstTime, useSettings } from '@/engine';
export const SoundToggle = () => {
const [settings, setSettings] = useSettings();
return (
setSettings({ sound: !settings.sound })}>
Sound: {settings.sound ? 'on' : 'off'}
);
};
// Anywhere: true the first time only, on this device.
if (firstTime('swipe-hint')) {
// show the hint
}
```
| Field | Default | Who reads it |
| --- | --- | --- |
| `sound` | `true` | The engine: the audio mixer's effect and UI buses, and one-shot sounds. |
| `music` | `true` | The engine: the audio mixer's music bus. |
| `haptics` | `true` | The engine: every `haptics.*` call. |
| `volume` | `1` | The engine: master volume, clamped to 0..1. |
| `leftHanded` | `false` | Your game: mirror the controls. |
| `sensitivity` | `1` | Your game: scale look, steering and aim. Clamped to 0.25..2. |
| `reducedMotion` | `false` | Your game: less shake, flashing and slow motion. |
| `seen` | `{}` | `firstTime(key)`. |
| `custom` | `{}` | Your own settings: `setSettings({ custom: { difficulty: 'hard' } })`. |
`setSettings` merges `seen` and `custom` key by key. Settings belong to the device and do not sync.
| Function | What it does |
| --- | --- |
| `useSettings()` | `[settings, setSettings]`, re-rendering when any screen changes them. |
| `getSettings()`, `setSettings(change)` | The same outside React. `getSettings` is cached and cheap. |
| `resetSettings()` | Back to `DEFAULT_SETTINGS`, keeping `seen`. |
| `onSettings(listener)` | Called after every change. Returns unsubscribe. |
| `firstTime(key)` | `true` the first time it is asked for `key`, `false` after. |
| `importSettings(read)` | Moves a game's own old setting keys over, once. |
## Share a score
```tsx
import React, { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import { ScoreCard, shareScore, type ScoreCardRef } from '@/engine';
export const Results = ({ score, best }: { score: number; best: number }) => {
const card = useRef(null);
return (
= best}
lines={['Day 4 streak']}
colors={{ background: '#0b1020', text: '#ffffff', accent: '#ffcc4d' }}
/>
shareScore(card, { message: `I ran ${score} m in Sky Runner. Beat that?` })}>
Share
);
};
```
| `ScoreCard` prop | Default | What it does |
| --- | --- | --- |
| `title` | required | The game's name. |
| `score` | required | A number (formatted with thousands separators) or a string. |
| `label` | `'SCORE'` | Under the score: `'BEST TIME'`, `'LEVEL'`. |
| `best` | none | Shown as "Best ..." unless `newBest` is set. |
| `newBest` | `false` | Shows a "NEW BEST" tag. |
| `lines` | `[]` | Small lines under the score. |
| `colors` | dark gray, white | `{ background, text, accent, muted? }`. |
| `fontFamily`, `bodyFontFamily` | system | Display font and UI font. `bodyFontFamily` defaults to `fontFamily`. |
| `width` | `300` | Card width in points. |
| `children` | none | Drawn above the title: an emblem, a medal. |
`shareScore(ref, { message })` captures the view as a PNG and opens the share sheet. It works with any view, not only `ScoreCard` (the puzzle module shares its results panel this way). It returns `false` if the player cancelled or sharing failed. Inside the Moonspell app it also appends the game's share link; elsewhere the message goes out without one.
## Sync
There is nothing to call. When `GAME_BACKEND_URL` in `src/engine/net/config.ts` is set:
- A save pulls the server's copy the first time it is read.
- It pushes a change about 1.5 seconds after it happens. Changes in between go together.
- While a screen shows it (`useSave` or `useProgression`), a live socket brings in what the player's other devices write.
- Conflicts merge: your `merge`, field by field in `defineProgression`, otherwise the copy changed last wins and the other is kept as a backup.
- `reset()` replaces the save on every device.
`useSyncStatus(save)` returns `'local'` (no backend), `'idle'`, `'syncing'`, `'offline'` or `'error'`, for a small cloud indicator if you want one.
**Each save has its own slots on the backend.** The default save (`key: 'save'`) stores a slot under its own id (`main`); a save with any other key stores it as `__` (`progress__main` for a `defineProgression`, a hashed form for keys with dots or colons such as the puzzle module's). So a `defineSave` and a `defineProgression` both on slot `main` never overwrite each other. Engines before 2026-11-11 stored every save under the bare slot id, so two saves in one game did overwrite each other there; the engine now repairs that by itself: a save with its own key reads its old copy once, when the data looks like its own, and copies it to its own id, and the default save ignores another save's leftover write in its slot and writes its own copy over it.
The player is the device's anonymous id (`getPlayerId()` from [Networking](https://docs.moonspell.app/engine/net.md)), the same one the leaderboards use. On the backend, saves live in a `PlayerSaves` Durable Object, one per player, with a SQLite copy of each slot. The backend accepts saves up to 64 KB of JSON. See [the backend overview](https://docs.moonspell.app/backend/overview.md) for how to set it up and how to make parts of an economy authoritative on the server.
Without a backend, every call above works the same and the data stays on the device. Saves start syncing on their own once a backend exists.
## Testing
`@/engine/save/testing` has two test doubles: `memoryStore()` (a device) and `memorySaveServer()` (the backend's write rule in memory). Use them to unit-test your migrations and merges with `node:test`. Import `defineSave` from `@/engine/save/save` in tests: the `@/engine` barrel pulls in React Native, which does not load under Node.
```ts
// src/game/progress.test.ts, run with: node --import tsx --test src/game/progress.test.ts
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { defineSave } from '@/engine/save/save';
import { memoryStore } from '@/engine/save/testing';
describe('save migrations', () => {
it('turns the v1 comma string into a list', () => {
const store = memoryStore();
// A v1 save, stored the way the engine stores it.
store.set('save.main', JSON.stringify({ v: 1, at: 1, epoch: 0, synced: 0, dirty: false, data: { coins: 5, skins: 'classic,neon' } }));
const save = defineSave({
version: 2,
defaults: { coins: 0, skins: ['classic'] },
migrations: { 2: (old) => ({ ...old, skins: String(old.skins).split(',') }) },
store,
sync: false,
});
assert.deepEqual(save.get().skins, ['classic', 'neon']);
});
});
```
To test two devices, give each its own `memoryStore()` and the same server: `defineSave({ ...spec, store: memoryStore(), transport: server.transport(), live: server.live, debounceMs: 60_000 })`, then call `syncNow()`. `server.slots` is keyed by the backend's ids: `main` for the default save, `progress__main` for a progression.
The pure helpers behind progression are exported too, if you want them without a save: `earn`, `spend`, `canAfford`, `missing`, `priceOf`, `checkBuy`, `buy`, `unlock`, `equip`, `addItem`, `consumeItem`, `rollLoot`, `addXp`, `checkIn`, `dailyReward`, `offlineEarnings`, `recordBest`, `betterOf`, `bump`, `fillDefaults`, `cloneJson`.
## Gotchas
- **Write on events, not every frame.** A coin pickup adds to a run total in memory; the end of the run calls `earn('coins', total)` once.
- **Saves are JSON.** No `Date`, `Map`, `Set` or class instances. Store times as numbers (`gameNow()`).
- **Do not mutate `get()`.** Change data only through `update` or `set`, or listeners will not fire and nothing is written.
- **Define a save once**, at module scope. Two `defineSave` calls with the same `key` in one game fight over the same storage.
- **Set `GAME_ID`** in `src/engine/net/config.ts` to your game's slug. It names the game's on-device store; two games with the same id on one device (or one web origin) share their saves. While it is still the template's placeholder, the engine uses the `slug` from your `app.json` instead and warns once in development, so two copies of the template only share saves if their `app.json` slugs are the same too.
- **Settings are not progress.** Never put coins in `custom`; it does not sync.
- **A fair economy needs the server.** On the device, a determined player can edit their save. Rewards with real value, trades and competitive economies belong on the backend.
## See also
- [Networking](https://docs.moonspell.app/engine/net.md): the player id, leaderboards and rooms.
- [Audio](https://docs.moonspell.app/engine/audio.md) and [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md): what follows the settings, and where to show currencies.
- [Puzzle module](https://docs.moonspell.app/modules/puzzle.md) and [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): both keep their progress in an engine save.
- [Examples](https://docs.moonspell.app/examples.md): the puzzle examples (Moon Blocks, Moonrise, Gem Cascade) and Oath of Ashvale save progress through their modules.
- [Backend overview](https://docs.moonspell.app/backend/overview.md): `PlayerSaves` and the rest of the game's backend.
---
# Networking
Source: https://docs.moonspell.app/engine/net
> Leaderboards, "N playing now" presence, multiplayer rooms and room links, with one API that works on the device alone and goes online when the game has a backend.
`src/engine/net` connects a game to other players: a leaderboard, a live count of who is playing, and multiplayer rooms that friends join with a code or a link. Every call works without a backend: the leaderboard lists this device's best runs, presence says 1, and a room holds only you. Set one URL and the same code goes online. You write the game once.
Everything below is exported from `@/engine`.
## Online or not: GAME_BACKEND_URL
`src/engine/net/config.ts` holds two constants:
```ts
export const GAME_BACKEND_URL: string | null = null; // the game's backend, e.g. 'https://my-game-api.example.workers.dev'
export const GAME_ID: string = gameIdFrom('__GAME_SLUG__'); // names this game's on-device data
```
`GAME_ID` is your game's slug once filled in. While it is still the template's placeholder, the engine falls back to the `slug` in your `app.json` (then `expo-constants`) and warns once in development; set it anyway.
Pass `GAME_BACKEND_URL` to every hook. With `null`, everything stays on the device. With a URL, the hooks talk to the game's backend: a Cloudflare Worker with one Durable Object per room, per leaderboard and for presence, and D1 for scores. Its routes come ready in the backend scaffold; see [the backend overview](https://docs.moonspell.app/backend/overview.md).
| Piece | Without a backend | With a backend |
| --- | --- | --- |
| `useLeaderboard` | This device's best runs (`status: 'local'`) | Shared by every player, live with `live: true` |
| `usePresence` | `{ count: 1, status: 'local' }` | How many have the game open right now |
| `useRoom` | A room with only you (`status: 'local'`) | A real-time room for up to the backend's `MAX_PLAYERS` (8 in the scaffold) |
| `requestTurn` | `{ ok: false, reason: 'no backend' }` | The computer opponent's model (see [Turns](https://docs.moonspell.app/engine/turns.md)) |
| `quickMatch` | A fresh room code with just you (`local: true`) | A room shared with whoever else asks in the next 45 seconds |
| Saves | Kept on the device | Synced (see [Saves](https://docs.moonspell.app/engine/saves.md)) |
## Smallest working example
A best score on a leaderboard and a "playing now" count. This is the shape [Tap Pop](https://docs.moonspell.app/examples.md) uses.
```tsx
import React, { useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import { GAME_BACKEND_URL, useLeaderboard, usePresence, type GameDefinition, type GameScreenProps } from '@/engine';
const Game = ({ onExit }: GameScreenProps) => {
const board = useLeaderboard(GAME_BACKEND_URL, { board: 'tap-count' });
const presence = usePresence(GAME_BACKEND_URL);
const [taps, setTaps] = useState(0);
return (
{presence.status !== 'local' && {presence.count} playing now}
Best: {board.you?.score ?? 0}
setTaps((n) => n + 1)}>
Tap ({taps})
void board.submit(taps).then(() => setTaps(0))}>
Submit
{board.entries.map((e) => (
{e.rank}. {e.name} {e.score}
))}
Exit
);
};
const game: GameDefinition = { id: 'tap-count', title: 'Tap Count', description: 'Tap, then post your count.', Component: Game };
export default game;
```
Online, every board name you pass must also be listed in the backend's `BOARDS`. An unknown board answers 404 and the score stays on the device.
## Leaderboards
```ts
import { GAME_BACKEND_URL, useLeaderboard } from '@/engine';
// Inside a component:
const board = useLeaderboard(GAME_BACKEND_URL, { board: 'rooftop-hop', order: 'asc', live: true });
await board.submit(timeMs, { meta: { level: 3 } });
```
| `useLeaderboard` option | Default | What it does |
| --- | --- | --- |
| `board` | `'global'` | The board's name. One game can have many: one per track, one per day (`daily-2026-11-11`). |
| `limit` | `10` | Entries to show. Live updates carry the top 20. |
| `live` | `false` | Keeps a socket open and updates the list the moment anyone posts a top score. |
| `order` | `'desc'` | Sort order on the device: `'desc'` for points, `'asc'` for times. Online, the backend's `BOARDS` decides. |
| `enabled` | `true` | `false` stays idle. |
It returns a `LeaderboardView`:
| Field | What it is |
| --- | --- |
| `entries` | `LeaderboardEntry[]`: `{ rank, player, name, score, at, meta? }`. `player` is a public id. |
| `you` | Your best and rank on this board, or `null`. |
| `order` | The board's order. |
| `status` | `'local'`, `'loading'`, `'ready'` or `'error'`. |
| `error` | The last error message, or `null`. |
| `submit(score, options?)` | Posts a score, also keeps it on the device, and refreshes. Returns a `SubmitOutcome`. |
| `refresh()` | Fetches the list again. |
| `ghost(player?)` | A best run's replay: that player's, or the board's number 1 without `player`. |
`submit` options: `name` (defaults to the player's chosen name), `meta` (small JSON, up to 1 KB, shown with the entry), `ghost` (a replay string up to 64 KB, kept only when it is the player's new best) and `order`. The outcome is `{ ok, local, improved, best, queued?, error? }`. When the network fails, the score is kept and sent with the next leaderboard call (`queued: true`). The device keeps only the best waiting score per board.
**Ghosts.** For anything raced or timed, sample your run (for example the position 10 times a second), encode it as a string and send it as `ghost`. Then `await board.ghost()` returns `{ board, player, name, score, ghost }` for the number 1 run, and you replay it.
Outside React: `fetchLeaderboard(url, query)`, `submitScore(url, score, options)`, `fetchGhost(url, query)`, `connectLeaderboardLive(url, board, handlers)` and `flushPendingScores(url)`.
## Presence
```ts
import { GAME_BACKEND_URL, usePresence } from '@/engine';
// Inside a component:
const presence = usePresence(GAME_BACKEND_URL); // { count, status }
```
`count` is how many copies of the game are open right now, this one included. `status` is `'local'` without a backend (count 1), otherwise `'connecting'`, `'open'`, `'reconnecting'` or `'closed'`. Hide the chip when it is `'local'`. Pass `{ enabled: false }` to stay idle. `connectPresence(url, handlers)` is the non-React version.
## Rooms
A room is a group of players who send each other their state many times a second, plus one-off events and a small shared state. The backend relays states as they come (`relay` mode), or batches them at a fixed rate (`tick` mode) when the game needs one authority.
```tsx
import { useFrame } from '@react-three/fiber';
import { GAME_BACKEND_URL, useRoom } from '@/engine';
type CarState = { x: number; z: number; heading: number };
const room = useRoom(GAME_BACKEND_URL, { roomId: code });
// Inside the scene: send your own state every frame; it goes out at sendHz.
useFrame(() => room.sendState({ x, z, heading }));
// Draw the others, interpolated about 100 ms in the past so they glide.
room.players.filter((p) => !p.isMe).map((p) => room.sample(p.id)} />);
room.sendEvent('honk');
room.setRoomState({ track: 'dunes' });
```
| `useRoom` option | Default | What it does |
| --- | --- | --- |
| `roomId` | required | The room code, case-insensitive (stored upper-case). An empty code stays disconnected. |
| `enabled` | `true` | `false` stays disconnected, for example until the player taps "Play with friends". |
| `name` | the player's chosen name | Display name in the room. Changing it renames you live. |
| `playerId` | this device's id | Override for tests. |
| `spectate` | `false` | Watch without a seat: receive everything, send no state. |
| `sendHz` | `15` | How often `sendState` really sends, from 1 to 30. The latest state wins. |
| `interpolationDelayMs` | 100 ms, or two ticks in tick mode | How far in the past `sample` draws remote players. |
| `onMessage` | none | Every server message. Kept in a ref, so an inline function is fine. |
It returns a `RoomView`:
| Field | What it is |
| --- | --- |
| `status` | `'idle'`, `'local'`, `'connecting'`, `'open'`, `'reconnecting'`, `'full'`, `'replaced'` or `'closed'`. `full` and `replaced` stay closed. |
| `me` | Your public id in the room, or `null` until welcomed. |
| `players` | Everyone, you included, each with `isMe`. Re-renders on join, leave and rename, not on every state. |
| `roomState` | The shared state. |
| `presence` | `{ count, spectators }` in this room. |
| `rtt` | Round trip in ms, or `null`. |
| `connection` | The live `RoomConnection`, for per-frame reads in `useFrame`. `null` while disabled. |
| `sendState(state)` | Queues your latest state. |
| `sendEvent(name, data?, { echo? })` | A one-off happening to the others (`echo` sends it back to you too). Queued while reconnecting, up to 32. |
| `setRoomState(patch)` | Shallow-merges into the shared state. `null` deletes a key. |
| `sample(id, lerp?)` | A player's interpolated state for this frame. |
Messages are JSON and at most 4 KB each; the shared room state is at most 8 KB. `sample` blends numbers, arrays and objects with `lerpState`. For an angle, write your own `lerp` that uses `lerpAngle`, which goes the short way round.
`connectRoom(url, options)` is the non-React version. It also has `serverNow()` (the server's clock, estimated), `on(listener)` and `subscribe(listener)`. `StateBuffer`, `lerpState`, `lerpAngle` and `ReconnectingSocket` are exported for your own netcode.
**Who decides.** In relay mode every device trusts the others. When an outcome must be fair (hits, tags, who grabbed the flag), either run the rules on the backend in tick mode or let one device host the match, as the [shooter module](https://docs.moonspell.app/modules/shooter.md) does: the oldest player's device runs the match and the others follow its snapshots.
**Quick match.** `quickMatch(GAME_BACKEND_URL, { size: 4 })` asks the backend's `POST /match` for a room: the same code for everyone who asks within 45 seconds, until `size` players (2 to 16, default 4) have it. Join the code with `useRoom`.
```ts
import { quickMatch } from '@/engine';
const match = await quickMatch(GAME_BACKEND_URL, { size: 2 });
if (match.ok) setRoomId(match.roomId); // { ok, local, roomId, seat, size }
else showCodeToShare(match.reason); // offline, slow or refused
```
It never throws. Without a backend it answers a fresh code with just this player (`local: true`), like every other net call; offline or on an error it answers `{ ok: false, reason }` (`timeoutMs` defaults to 8000).
## Room codes and links
```ts
import { makeRoomCode, roomInviteUrl, useLaunchRoomId } from '@/engine';
const launchRoom = useLaunchRoomId(); // the code from a ?room=CODE link, or null
const code = launchRoom ?? makeRoomCode(); // e.g. "K7QXM"
const link = roomInviteUrl(code); // the share link with ?room=CODE, or null
```
| Function | What it does |
| --- | --- |
| `makeRoomCode(length?)` | A fresh code, 5 characters by default, without look-alike characters (no 0, O, 1, I or L). |
| `roomFromUrl(url)` | The `room` parameter of a link (query or fragment), upper-cased, or `null`. |
| `useLaunchRoomId()` | The room the game was opened into. It also reads React Native's initial URL and links that arrive while the game is open. |
| `getLaunchRoomId()`, `getLaunchUrl()` | The same, read once, from the link the app passed the game. |
| `getShareUrl()` | The game's public share link, if the app around the game knows it. |
| `roomInviteUrl(code)` | The share link with `?room=CODE`, or `null` when the share link is unknown. |
The game's share link and launch link come from the app that hosts the game. Inside the Moonspell app (and its web player) the app passes them in. In a browser the engine reads them from the page's query string: `getShareUrl()` returns the `share` parameter and `getLaunchUrl()` the `launch` parameter, so a plain browser tab with no `?share=` gets `null`. In your own Expo build on a phone there is no shell module, so `getShareUrl()` and `roomInviteUrl()` always return `null`. When they do, share the code itself as a fallback, as the shooter module does:
```ts
import { Share } from 'react-native';
import { roomInviteUrl } from '@/engine';
const url = roomInviteUrl(code);
await Share.share(url ? { message: `Join my match: ${url}`, url } : { message: `Join my match with code ${code}` });
```
`useLaunchRoomId()` still picks up `?room=CODE` from the page URL on web, and from a deep link on phones if your build has a URL scheme.
## Player identity
| Function | What it does |
| --- | --- |
| `getPlayerId()` | This device's secret player id, created on first use. Send it only to the backend, never show it. The backend hashes it into the public id others see. |
| `getPlayerName()`, `setPlayerName(name)` | The name the player chose, or `null`. `setPlayerName` cleans it (no control characters, single spaces, at most 20 characters). |
| `usePlayerName()` | `[name or null, setName]`, re-rendering when any component changes the name. |
| `defaultPlayerName()`, `displayName()` | "Player 4821", stable per device, and the chosen name or that default. |
| `cleanPlayerName(raw)` | The same cleaning rules as the backend. |
| `hasBackend(url)` | Whether a URL is set and looks like `http(s)` or `ws(s)`. |
## Gotchas
- **List every board on the backend.** Each `board` your game passes must be in the backend's `BOARDS`, by name or as a family (`"daily-*"` covers `daily-2026-11-11`). Otherwise scores stay on the device and the list stays empty.
- **One code path.** Do not branch on `GAME_BACKEND_URL` for gameplay. Branch only on what you show: hide presence at `'local'`, hide the invite button when `hasBackend(GAME_BACKEND_URL)` is false.
- **Send state, not events, for movement.** `sendState` is throttled and only the latest one goes out. `sendEvent` is for things that happen once.
- **Read per-frame data from `room.connection`** inside `useFrame`, not from `room.players`, which only re-renders on joins and leaves.
- **Rooms are live.** A room exists while players are connected. Turns across days need their own storage on the backend.
- **Never point a shipped build at a local backend.** `http://127.0.0.1:8787` is for testing with `wrangler dev` only.
## See also
- [Saves and progression](https://docs.moonspell.app/engine/saves.md): the same player id names the player's saves.
- [Turns](https://docs.moonspell.app/engine/turns.md): `useTurnRoom` builds online turn-based play on `useRoom`.
- [Shooter module](https://docs.moonspell.app/modules/shooter.md): host-runs-the-match multiplayer on relay rooms.
- [Examples](https://docs.moonspell.app/examples.md): Tap Pop (a leaderboard), Moon Chess (rooms from a link), Flag Rush (rooms and invites).
- [Backend overview](https://docs.moonspell.app/backend/overview.md): the Worker, `BOARDS`, room modes and how to deploy.
---
# Story
Source: https://docs.moonspell.app/engine/story
> Dialogue with cut-out portraits, choices on cards, reward sheets, objectives and chapters, written as data and run by a small saveable state machine.
The story kit gives a game a story told in beats: lines of dialogue with a speaker's portrait, a choice on two or three cards, an objective the game must report progress on, a reward sheet, and chapter titles. You write the story as plain data. A pure runner moves through it and returns a small JSON state you can save. Three components draw the current beat.
Use it for any game with a narrator or characters who talk: a town builder with a quest line, an adventure, a tutorial told by a character. The [kingdom module](https://docs.moonspell.app/modules/kingdom.md) is built on it and is the reference user.
Everything below is exported from `@/engine`.
## Smallest working example
A two-line conversation, a choice, an objective the player completes by tapping, and a reward.
```tsx
import React, { useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
ChoiceCards,
DialogueBox,
RewardSheet,
advance,
choose,
createStoryState,
currentBeat,
currentObjective,
report,
skipDialogue,
speakerOf,
type GameDefinition,
type GameScreenProps,
type Story,
} from '@/engine';
const STORY: Story = {
speakers: [
{ id: 'queen', name: 'Queen Maren', role: 'Ruler of Ashvale', color: '#7A4BC2', portrait: { icon: 'crown.fill' } },
{ id: 'tobbin', name: 'Old Tobbin', role: 'Farmer', side: 'right' },
],
chapters: [
{
id: 'ch1',
title: 'The harvest',
beats: [
{ kind: 'title', text: 'Chapter 1', sub: 'The harvest' },
{ kind: 'say', speaker: 'queen', text: 'The granary is empty. We need wheat before winter.' },
{ kind: 'say', speaker: 'tobbin', text: 'Then pick a field and I will show you how.' },
{
kind: 'choice',
id: 'field',
title: 'Which field?',
options: [
{ id: 'river', label: 'River', detail: 'Wet soil, slow but sure', icon: 'drop.fill' },
{ id: 'hill', label: 'Hill', detail: 'Dry soil, quick but thin', icon: 'sun.max.fill' },
],
},
{ kind: 'objective', id: 'harvest', text: 'Harvest wheat', target: 3 },
{ kind: 'reward', title: 'Harvest', items: [{ resource: 'Wheat', amount: 3 }] },
],
},
],
};
const Game = ({ onExit }: GameScreenProps) => {
const [state, setState] = useState(createStoryState);
const beat = currentBeat(STORY, state);
const objective = currentObjective(STORY, state);
const harvest = () =>
setState((s) => {
const next = report(s, 'harvest');
const b = currentBeat(STORY, next);
// An objective blocks advance until its target is met; then move on.
return b?.kind === 'objective' ? advance(STORY, next) : next;
});
return (
{objective && (
{objective.text}: {objective.done} of {objective.target}
Harvest
)}
{!beat && (
The end. Tap to exit.
)}
{beat?.kind === 'title' && (
setState((s) => advance(STORY, s))}>
{beat.text}
{beat.sub ? {beat.sub} : null}
)}
{beat?.kind === 'say' && (
setState((s) => advance(STORY, s))}
onSkip={() => setState((s) => skipDialogue(STORY, s))}
/>
)}
{beat?.kind === 'choice' && (
setState((s) => choose(STORY, s, id))} />
)}
{beat?.kind === 'reward' && (
({ label: it.resource, amount: it.amount }))}
button="Take it"
onDone={() => setState((s) => advance(STORY, s))}
/>
)}
);
};
const game: GameDefinition = { id: 'harvest-tale', title: 'Harvest Tale', description: 'A short story about wheat.', Component: Game };
export default game;
```
To keep the story across sessions, store the `StoryState` in a save instead of `useState` (see [Persisting the story](https://docs.moonspell.app/engine/story.md#persisting-the-story)).
## Beats
A `Story` is `{ speakers, chapters }`. Each `Chapter` is `{ id, title, beats }`, and the runner plays the beats in order.
| `kind` | Fields | Waits for |
| --- | --- | --- |
| `title` | `text`, `sub?` | Whatever your game uses to continue (usually a tap), then `advance`. |
| `say` | `speaker` (a speaker id), `text` | A tap on `DialogueBox`. The first tap finishes the typing, the second calls `onAdvance`. |
| `choice` | `id`, `title`, `options`, `confirm?` | The confirm button on `ChoiceCards`, then `choose`. `advance` refuses to leave an unanswered choice. |
| `objective` | `id`, `text`, `target?` (default 1), `hint?` | Progress reported with `report` or `reportTotal` reaching `target`. `advance` refuses to leave it before then. |
| `reward` | `title?`, `items: { resource, amount }[]` | The button on `RewardSheet`. Grant the items yourself when it is pressed, then `advance`. |
| `scene` | `scene`, `args?` | Something only your game knows how to do (start a battle, pan the camera, unlock a building). Do it, then `advance`. |
A `Speaker` is `{ id, name, role?, color?, portrait?, side? }`. `side` is `'left'` (default) or `'right'`. A `ChoiceOption` is `{ id, label, detail, icon?, portrait? }`.
## Portraits
A `Portrait` is either a cell of a sprite sheet, `{ source, cols, rows, frame }`, or an SF Symbol, `{ icon }`. Cells count in reading order: left to right, top to bottom, from 0.
```ts
const SHEET = { source: require('../../assets/sprites/oath-of-ashvale/sheet.png'), cols: 3, rows: 3 };
const portrait = (frame: number) => ({ ...SHEET, frame });
const speakers = [{ id: 'queen', name: 'Queen Maren', portrait: portrait(0) }];
```
This is how [Oath of Ashvale](https://docs.moonspell.app/examples.md) gives its queen, marshal and farmer their cut-outs. Until a sheet exists, or with no portrait at all, `PortraitView` draws the icon on a coloured disc, or the speaker's initials (`monogramOf(name)`). The sheet is drawn with `SheetImage`; see [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md).
## The runner
Every function is pure and returns a new state, or the same object when nothing changed. That makes it safe inside `setState` and inside a save's `update`.
| Function | What it does |
| --- | --- |
| `createStoryState()` | A fresh state at chapter 0, beat 0. |
| `restoreStoryState(story, saved)` | A saved state checked against the story it will run, so a save from before you edited the chapters still loads. |
| `currentChapter(story, s)`, `currentBeat(story, s)` | What is playing now, or `null` when the story is finished. |
| `advance(story, s)` | The next beat, or the next chapter. Refuses to leave an unmet objective or an unanswered choice. |
| `choose(story, s, optionId)` | Records the pick for the current choice beat and advances. |
| `report(s, objectiveId, amount?)` | Adds progress (default 1) to any objective, met or not. |
| `reportTotal(s, objectiveId, total)` | Sets progress to a count your game already keeps: buildings standing, wheat held. |
| `objectiveDone(s, beat)`, `targetOf(beat)` | Whether an objective beat is met, and its target. |
| `currentObjective(story, s)` | `{ id, text, hint?, done, target }` for a quest tracker, or `null`. |
| `skipDialogue(story, s)` | Jumps over `say` and `title` beats to the next beat that needs the player (choice, objective, scene, reward) or the next chapter. |
| `isDialogueBeat(beat)` | `true` for `say`, `choice`, `reward` and `title`: the beats that cover the game's controls. |
| `speakerOf(story, id)` | The speaker, or `{ id, name: id }` when the id is unknown. |
`StoryState` is `{ chapter, beat, choices, progress, finished }`. `choices` holds each choice's pick by its `id`, so later code can read `state.choices.field`. Progress is kept by objective id across beats: if the player harvests before the story asks for it, the work still counts when the objective comes up.
## Components
All three cover the whole screen while they are up. Give each the same `theme` so they match your game.
### DialogueBox
| Prop | Default | What it does |
| --- | --- | --- |
| `speaker` | required | The `Speaker` to show: portrait, name, role. |
| `text` | required | The line, typed out. |
| `onAdvance` | required | Called on the tap after the line is fully shown. |
| `onSkip` | none | Shows a Skip pill at the top right. Hidden when not set. |
| `theme` | `DEFAULT_STORY_THEME` | Colours and font. |
| `speed` | `45` | Characters per second. |
| `skipTop` | `110` | Top inset of the Skip pill, to sit under your HUD's top bar. |
### ChoiceCards
| Prop | Default | What it does |
| --- | --- | --- |
| `title` | required | The question. |
| `options` | required | Two or three `ChoiceOption`s, shown as cards. The first is selected at the start. |
| `confirm` | `'Choose'` | The confirm button's label. |
| `onChoose` | required | Called with the selected option's id. |
| `theme` | `DEFAULT_STORY_THEME` | Colours and font. |
### RewardSheet
| Prop | Default | What it does |
| --- | --- | --- |
| `title` | required | The banner word: "Victory", "Harvest". |
| `subtitle` | `'Rewards'` | The line above the items. |
| `items` | required | `RewardRow[]`: `{ label, amount, picture?, color? }`. `picture` is a `Portrait`. |
| `button` | required | The button's label. |
| `onDone` | required | Called when the button is pressed. |
| `theme` | `DEFAULT_STORY_THEME` | Colours and font. |
| `bannerColor` | the theme's accent | The banner's colour. |
| `testID` | `'reward-sheet'` | Test id of the sheet. |
`RewardSheet` is not tied to the story: use it for any payoff moment, such as a won battle.
### PortraitView
`PortraitView` draws one portrait with its fallback: `portrait?`, `monogram`, `color`, `size`, `fontFamily?` and `shape` (`'disc'` by default, or `'cutout'` for a bare cut-out).
### Theme
`StoryTheme` is `{ fontFamily?, panel, text, dim, accent, onAccent, scrim }`. `DEFAULT_STORY_THEME` is a white panel with dark text and a red accent. Pass your game's palette and the font family you loaded with `useGameFonts` (see [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)).
The components set test ids for automated playtests: `dialogue`, `dialogue-name`, `dialogue-text`, `btn-skip`, `choice-cards`, `choice-`, `choice-detail`, `btn-choice-confirm`, `reward-title`, `btn-reward-done`.
## Persisting the story
`StoryState` is small JSON. Keep it in a save and read it back with `restoreStoryState`, the way the kingdom module does:
```tsx
import { useMemo } from 'react';
import { advance, createStoryState, currentBeat, defineSave, restoreStoryState, useSave, type Story, type StoryState } from '@/engine';
const save = defineSave({ version: 1, key: 'story', defaults: { story: createStoryState() } });
export function useStory(story: Story) {
const data = useSave(save);
const state = useMemo(() => restoreStoryState(story, data.story), [story, data.story]);
const setStory = (fn: (s: StoryState) => StoryState) =>
save.update((d) => ({ ...d, story: fn(restoreStoryState(story, d.story)) }));
return { state, beat: currentBeat(story, state), next: () => setStory((s) => advance(story, s)), setStory };
}
```
See [Saves and progression](https://docs.moonspell.app/engine/saves.md) for versions, migrations and sync.
## Gotchas
- **Nothing advances by itself.** The runner only moves when you call `advance`, `choose` or `skipDialogue`. A met objective waits until you advance; a `scene` beat waits until your game has done the scene and advances.
- **Grant rewards yourself.** The `reward` beat only describes the items. Add them to the wallet when `onDone` fires, then advance.
- **`title` beats have no component.** Draw them your own way (a full-screen chapter card) and advance on a tap.
- **Ids are forever.** Choices and objectives are saved by id. Renaming one in a later version loses that progress; add a new id instead.
- **Edit chapters safely.** Always load saved states through `restoreStoryState`, which clamps the chapter and beat to the story as it is now.
- **No emoji in lines.** Write dialogue as plain text; use `icon` or `portrait` for pictures.
## See also
- [Kingdom module](https://docs.moonspell.app/modules/kingdom.md): a town builder whose story drives building, farming and battles, with objective ids it reports by itself.
- [Examples](https://docs.moonspell.app/examples.md): Oath of Ashvale, two chapters with cut-out portraits and a knight-or-ranger choice.
- [Saves and progression](https://docs.moonspell.app/engine/saves.md): keeping the story state.
- [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md): fonts, icons and `SheetImage`.
---
# Turns
Source: https://docs.moonspell.app/engine/turns
> Turn-based games described once as TurnRules, with full chess, a look-ahead search, a computer opponent at three levels that can talk through the game's backend, and online turns over a room.
The turns kit is for games where players take turns: chess, checkers, connect four, reversi, tic-tac-toe, a card duel. You describe the game once as `TurnRules`. The kit then gives you a search that scores every move, a computer opponent at easy, medium or hard, and online play with a friend over a room. Chess comes built in as `chessRules`.
The kit has no drawing of its own. Draw the board with the [2D layer](https://docs.moonspell.app/engine/2d.md) or React Native views. [Moon Chess](https://docs.moonspell.app/examples.md) is a complete game on all of it.
Everything below is exported from `@/engine`.
## Smallest working example
Tic-tac-toe rules (the same rules the engine's own search tests use) and a computer move.
```ts
import { createOpponent, searchMoves, type TurnRules } from '@/engine';
interface Ttt {
cells: number[]; // 0 empty, 1 X, -1 O
turn: 1 | -1;
}
const LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
const winner = (c: number[]) => {
for (const [a, b, d] of LINES) if (c[a] !== 0 && c[a] === c[b] && c[a] === c[d]) return c[a];
return 0;
};
export const tttRules: TurnRules = {
moves: (s) => (winner(s.cells) ? [] : s.cells.flatMap((c, i) => (c === 0 ? [i] : []))),
play: (s, m) => {
s.cells[m] = s.turn;
s.turn = s.turn === 1 ? -1 : 1;
},
undo: (s, m) => {
s.cells[m] = 0;
s.turn = s.turn === 1 ? -1 : 1;
},
outcome: (s, moves) => {
if (winner(s.cells)) return -1; // the player who just moved made the line
return (moves ?? tttRules.moves(s)).length === 0 ? 0 : null;
},
evaluate: () => 0,
order: (_s, m) => (m === 4 ? 2 : m % 2 === 0 ? 1 : 0), // centre, then corners
notation: (_s, m) => `${'abc'[m % 3]}${3 - Math.floor(m / 3)}`,
};
const state: Ttt = { cells: Array(9).fill(0), turn: 1 };
// Every legal move, best first, for the side to move.
const { ranked } = searchMoves(tttRules, state, { depth: 9 });
// Or let an opponent pick one at a level.
const opponent = createOpponent({ rules: tttRules, game: 'tic-tac-toe', level: 'easy' });
const turn = await opponent.takeTurn(state, []);
if (turn) tttRules.play(state, turn.move);
```
In a component, use `useOpponent` instead of `createOpponent` (it cancels the turn in progress on unmount).
## TurnRules
The state is mutable. The search plays and undoes thousands of moves on one state object instead of copying a board per position, which is what makes it fast enough on a phone.
| Member | Required | What it does |
| --- | --- | --- |
| `moves(state)` | yes | Legal moves for the side to move. An empty list means the game is over. |
| `play(state, move)` | yes | Plays the move in place. |
| `undo(state, move)` | yes | Takes back exactly what `play` changed. |
| `outcome(state, moves?)` | yes | Once over, the result for the side to move: `1` won, `0` draw, `-1` lost. `null` while the game goes on. The search passes the moves it generated for that position; decide "no moves left" from them instead of generating again. |
| `evaluate(state)` | yes | How good the position is for the side to move. 100 is about one "pawn", a modest edge. |
| `notation(state, move)` | yes | The move as players read it: "Nf3", "c3-d4", "column 4". Unique per legal move, and readable back by your game. |
| `tacticalMoves(state)` | no | Moves that change the position sharply (captures, promotions), searched past the depth limit. Leave it out for quiet games. |
| `order(state, move)` | no | Higher is searched first. Better ordering means a deeper search in the same time. |
| `describe(state)` | no | The position as text for the opponent's model: a FEN and a diagram, a hand, a score line. |
| `key(state)` | no | A cheap key for the position (a hash or a short string). |
Test `undo` before anything else: play every legal move, undo it, and compare a serialised copy of the state with the one before.
## Chess
`chessRules` is full chess, tested against the standard perft move counts: castling, en passant, promotion to all four pieces, check, checkmate, stalemate, the fifty-move rule, threefold repetition and dead positions.
```ts
import { chessRules, chessStatus, createChess, legalMovesFrom, moveFromSan, playMove, san, squareOf } from '@/engine';
const chess = createChess(); // or createChess(fen)
const moves = legalMovesFrom(chess, squareOf('e2'));
const e4 = moveFromSan(chess, 'e4');
if (e4) {
const text = san(chess, e4); // notation is read before the move is played
playMove(chess, e4);
}
const status = chessStatus(chess); // { over, result, reason, check, turn }
```
| Export | What it does |
| --- | --- |
| `createChess(fen?)`, `toFen(state)`, `cloneChess(state)`, `START_FEN` | A state from and to FEN. |
| `legalMoves(state)`, `legalMovesFrom(state, sq)` | `ChessMove[]`: `{ from, to, piece, color, captured?, promotion?, flags }`. |
| `playMove(state, move)`, `undoMove(state, move)` | Play and take back in place. |
| `inCheck(state)`, `pieceAt(state, sq)` | Whether the side to move is in check; the `ChessPiece` (`{ type, color }`) on a square, or `null`. |
| `squareName(sq)`, `squareOf(name)` | Squares are 0 to 63: 0 is a1, 7 is h1, 56 is a8. |
| `san(state, move)`, `moveFromSan(state, text)` | Standard algebraic notation. Call `san` before playing the move. |
| `uci(move)`, `moveFromUci(state, text)` | UCI notation ("e2e4"). |
| `chessStatus(state)` | `{ over, result, reason, check, turn }`. `result` is `'1-0'`, `'0-1'` or `'1/2-1/2'`; `reason` is `'checkmate'`, `'stalemate'`, `'fifty-move'`, `'repetition'` or `'insufficient'`. |
| `evaluateChess(state)` | Evaluation in centipawns for the side to move. |
| `asciiBoard(state)` | A text diagram, used by `describe` for the model. |
| `MOVE_FLAGS`, `CASTLING` | Bits of `ChessMove.flags` and `ChessState.castling`. |
`chessRules` uses `san` as its notation, so the opponent and online play exchange moves in SAN.
## Search
```ts
import { chessRules, createChess, pickMove, searchMoves, searchMovesAsync } from '@/engine';
const chess = createChess();
const { ranked, depth, nodes, ms } = searchMoves(chessRules, chess, { depth: 4, timeMs: 500 });
const pick = pickMove(ranked, { margin: 80, temperature: 30 });
// In a game: give the UI a frame between root moves so the board keeps animating.
const result = await searchMovesAsync(chessRules, chess, { depth: 4, timeMs: 700 });
```
The search is negamax with alpha-beta pruning, iterative deepening under a time budget (it keeps the last depth it finished), and a quiescence pass over `tacticalMoves` at the horizon. It scores every root move, not only the best, which is what an opponent at a level needs. Neither function leaves the state changed.
| `SearchOptions` | Default | What it does |
| --- | --- | --- |
| `depth` | `4` | Deepest look-ahead in plies. |
| `timeMs` | `500` | Time budget. Depth 1 always finishes. |
| `quiescence` | `6` (0 without `tacticalMoves`) | Extra plies of tactical moves past the horizon. |
| `spread` | `Infinity` | Root moves this far below the best are only proven worse, not scored exactly. Faster. |
| `maxNodes` | none | Stop after this many positions (tests). |
| `now` | `Date.now` | The clock. |
| `signal` | none | `{ aborted }`: set `aborted = true` to stop the async search at the next root move. |
| `sliceMs` | `24` | `searchMovesAsync` only: how long it runs before giving the thread back for a frame. |
The result is `{ ranked, depth, nodes, ms, stopped }`, where `ranked` is `{ move, score }[]`, best first.
A forced win scores `WIN_SCORE - plies` (`WIN_SCORE` is 1,000,000). `isWinScore(score)` tells a forced result from an evaluation, and `pliesToEnd(score)` how far away it is (positive when the mover wins).
`pickMove(ranked, { margin, temperature, rng? })` picks the way a player at a level would: the best move at temperature 0, otherwise a weighted choice where a move `temperature` units worse is about 2.7 times less likely. Only moves within `margin` of the best count. It never picks a forced loss over a move that is not one.
In Hermes, chess searches about 70,000 positions a second (depth 4 in about 1.5 s from the opening). Use `searchMovesAsync` for anything that takes more than a few milliseconds, and do not change the state until it resolves.
## The computer opponent
```tsx
import { GAME_BACKEND_URL, chessRules, createChess, playMove, useOpponent } from '@/engine';
const chess = createChess();
const sans: string[] = []; // the game so far, in SAN
// Inside a component:
const opponent = useOpponent({
rules: chessRules,
game: 'chess',
level: 'medium',
persona: 'Vesper, a sly night fox who teases you when you slip, always fair.',
side: 'Black',
backendUrl: GAME_BACKEND_URL,
});
// On the computer's turn:
const turn = await opponent.takeTurn(chess, sans);
if (turn) {
sans.push(turn.notation);
playMove(chess, turn.move);
if (turn.say) console.log(turn.say); // show it in a speech bubble
}
// Undo, restart or exit while it thinks:
opponent.cancel();
```
Each turn the opponent:
1. Searches the position on the device with `searchMovesAsync`.
2. Keeps a shortlist of moves a player at its level would consider.
3. With a backend, asks the backend's model to choose one of those moves in character and say a line to the player.
4. Otherwise, or when the model is slow, busy or answers with a move not on the list, plays its own pick from the same shortlist, silently.
The model never sees an illegal move, and the level decides how strong the moves on the list are. The model only adds the personality and the talk. The backend runs the model on Cloudflare Workers AI behind a `POST /ai/turn` route with a per-player rate limit; see [the backend overview](https://docs.moonspell.app/backend/overview.md). `requestTurn(url, ask)` calls that route directly if you write your own opponent loop.
| `OpponentConfig` | Default | What it does |
| --- | --- | --- |
| `rules` | required | Your `TurnRules`. |
| `game` | required | What is being played, for the model: `'chess'`. |
| `level` | required | `'easy'`, `'medium'` or `'hard'`. |
| `persona` | none | A name and a personality in a sentence or two, for the model. |
| `side` | none | The side it plays, for the model: `'Black'`. |
| `backendUrl` | none | `GAME_BACKEND_URL`. Without it, the opponent always plays its own pick and says nothing. |
| `unit` | `100` | Evaluation units per "pawn". Scales the level's margin and temperature. |
| `tune` | none | Overrides fields of the level's preset: `{ timeMs: 2500 }`. |
| `timeoutMs` | `8000` | How long to wait for the model. |
| `formatScore` | `defaultFormatScore(unit)` | The note the model sees for each move: "+0.4", "wins in 2". |
| `rng` | `Math.random` | Random source, for repeatable tests. |
`OPPONENT_LEVELS` holds the presets:
| Level | `depth` | `timeMs` | `options` | `margin` | `temperature` |
| --- | --- | --- | --- | --- | --- |
| `easy` | 2 | 300 | 6 | 400 | 120 |
| `medium` | 3 | 700 | 5 | 120 | 35 |
| `hard` | 6 | 1500 | 3 | 30 | 0 |
`margin` and `temperature` are in hundredths of `unit` (centipawns in chess). The shortlist (`shortlist(ranked, count, margin, rng)`) takes the best half of `options` in order and draws the rest at random from what is within the margin. A move that loses by force never goes on the list while something else exists.
`takeTurn(state, history)` resolves to an `OpponentMove`, or `null` when `cancel()` or a newer turn dropped it:
| Field | What it is |
| --- | --- |
| `move`, `notation` | The move and its notation. |
| `say` | The line to show, or `''`. At most 120 characters. |
| `source` | `'model'` or `'search'`. |
| `reason` | Why the model did not choose, when it did not: `'no backend'`, `'timed out'`, `'429 ...'`. |
| `score` | The search's score for the move. |
| `depth`, `nodes`, `options`, `ms` | How the turn went. |
## Online turns
`useTurnRoom` builds turn-based play on a multiplayer room ([Networking](https://docs.moonspell.app/engine/net.md)): seats, whose turn it is, the moves so far, resign and rematch.
```tsx
import { useState } from 'react';
import { GAME_BACKEND_URL, displayName, useLaunchRoomId, useTurnRoom } from '@/engine';
const launchRoom = useLaunchRoomId();
const [code, setCode] = useState(launchRoom);
const table = useTurnRoom(GAME_BACKEND_URL, { roomId: code ?? '', name: displayName(), enabled: !!code });
// My move: pass the number of moves I saw when I chose it.
table.play(notation, table.moves.length);
table.finish(`resign:${table.mySeat}`); // or 'draw'
table.rematch(); // new game, seats rotated: the other side starts
```
| `UseTurnRoomOptions` | Default | What it does |
| --- | --- | --- |
| `roomId` | required | The room code. Empty stays disconnected. |
| `name` | the player's name | Display name. |
| `enabled` | `true` | `false` stays disconnected. |
| `onMessage` | none | Every server message, for emotes sent with `room.sendEvent`. |
| `seats` | `2` | Players who take turns. |
| `TurnRoomView` | What it is |
| --- | --- |
| `room` | The `RoomView` underneath: status, presence, `sendEvent`. |
| `mySeat` | This device's seat (seat 0 moves first), or `null` while waiting or watching. |
| `seated` | Each seat's player while they are in the room, `null` for a free seat or one whose player stepped away. |
| `seatIds` | Each seat's public id, kept while its player is away. |
| `full` | Every seat has a player in the room. |
| `moves` | Every move so far, in notation. Your own move shows at once. |
| `game` | Game number in this room: 1, then 2 after a rematch. |
| `result` | How the game ended outside its moves (`'resign:1'`, `'draw'`), or `null`. |
| `turn`, `myTurn` | Whose seat moves now, and whether it is this device. |
| `play(move, ply)` | Sends my move. Ignored, and returns `false`, when it is not my turn or `ply` is stale (a double tap). |
| `finish(result)` | Ends the game for everyone. |
| `rematch()` | A new game in the same room, seats rotated. |
**The moves are the truth.** The room's shared state holds `seats`, `moves`, `game` and `result`, and every device replays `moves` through its own rules, so the boards never disagree. When `table.moves` changes, apply the new moves; when they no longer extend yours (a rematch), rebuild the board from scratch. Each device takes the first free seat. A player who drops out keeps their seat and gets the whole game back when they reconnect. A third player watches.
`readTurnRoom`, `claimSeat` and `turnSeat` are the pure parts of `useTurnRoom`, exported for tests.
Without a backend, `useTurnRoom` stays in a room with only you, so offer pass-and-play or the computer instead.
## Gotchas
- **`undo` must restore everything `play` changed**, including side to move, counters and caches. A leak here shows up as illegal moves deep in the search.
- **Do not touch the state while a search or `takeTurn` runs.** It plays and undoes moves on your object. Cancel first (`opponent.cancel()`), then undo or restart.
- **Notation is the wire format.** The model answers with one of your notations and online play stores them, so each must be unique and parse back.
- **Evaluate in pawns of 100.** The level presets assume that scale. If your evaluation uses other units, pass `unit`.
- **Online games are live.** Both players have the game open. The scaffold's room keeps the game for 30 minutes after the last player leaves. There are no turns across days, no notifications and no clocks yet; keep a per-turn deadline in the room state if you need one.
- **The strength comes from the search.** Hard is a careful club player, not a grandmaster. Without a backend the opponent plays as well but says nothing.
## See also
- [Examples](https://docs.moonspell.app/examples.md): Moon Chess, with three rivals who talk, a friend online from a link, pass-and-play, take-backs and a promotion picker.
- [2D layer](https://docs.moonspell.app/engine/2d.md): drawing the board and the pieces.
- [Networking](https://docs.moonspell.app/engine/net.md): rooms, room codes and invite links.
- [Backend overview](https://docs.moonspell.app/backend/overview.md): the `POST /ai/turn` route and its limits.
---
# Utilities
Source: https://docs.moonspell.app/engine/utilities
> The small shared pieces of the engine, math helpers, paths and the things that follow them (followers, moving platforms, camera dollies, path meshes), trigger zones and typed signals, and the development debug overlay.
Four small parts of the engine that every genre uses, all exported from `@/engine`:
| Part | Folder | For |
| --- | --- | --- |
| Math | `math.ts` | Clamping, smoothing, angles, time strings, a seeded random generator |
| Paths | `paths/` | Smooth curves in metres and things that move along them |
| Zones and signals | `zones/` | Trigger areas without physics, and a typed event bus |
| Debug | `debug/` | The development readout: fps, frame time, draw calls, lines from each module |
## Math (`math.ts`)
| Export | What it does |
| --- | --- |
| `clamp(v, min, max)` | `v` limited to the range |
| `lerp(a, b, t)` | Linear blend |
| `damp(a, b, lambda, dt)` | Frame-rate independent smoothing toward `b`; higher `lambda` is snappier. Use it instead of `lerp` with a fixed factor inside `useFrame` |
| `TAU` | `Math.PI * 2` |
| `angleDelta(a, b)` | The shortest signed angle from `a` to `b`, in radians |
| `formatTime(seconds)` | `'01:05.3'`; `'--:--.-'` for negative or non-finite values |
| `makeRng(seed)` | A deterministic random function (mulberry32) returning 0 to 1, so procedurally placed scenery is the same on every load |
```ts
import { damp, makeRng } from '@/engine';
const rng = makeRng(11);
const trees = Array.from({ length: 40 }, () => ({ x: (rng() - 0.5) * 80, z: (rng() - 0.5) * 80 }));
export function followCamera(current: number, target: number, dt: number) {
return damp(current, target, 6, dt);
}
```
## Paths (`paths/`)
A `Path` is a smooth curve through points, measured in metres: `pointAt(d)` is the point `d` metres along it, whatever the spacing of the control points. Moving platforms, camera dollies, patrol routes and the racing track all use the same type.
```tsx
import React from 'react';
import { CameraDolly, PathFollow, createPath } from '@/engine';
// A slow orbit over the arena for the title screen (from the Lantern Tag example).
const ORBIT = createPath(
Array.from({ length: 12 }, (_, i) => [Math.cos((i / 12) * Math.PI * 2) * 24, 12, Math.sin((i / 12) * Math.PI * 2) * 24] as [number, number, number]),
{ closed: true },
);
const PATROL = createPath([[-6, 0, 0], [6, 0, 0], [6, 0, 6]], { curve: 'linear' });
export const TitleScene = ({ onTitle }: { onTitle: () => boolean }) => (
<>
>
);
```
### `createPath(points, options?)`
`points` are `[x, y, z]` triples or `{ x, y, z }` objects; at least two. Returns a `Path` (also constructible with `new Path(points, options)`).
| Option | Default | What it does |
| --- | --- | --- |
| `closed` | `false` | Join the end back to the start |
| `curve` | `'centripetal'` | `'centripetal'` (no loops or cusps), `'catmullrom'`, `'chordal'`, or `'linear'` (straight segments) |
| `tension` | `0.5` | Catmull-Rom tension, for `'catmullrom'` |
| `samples` | one per half metre, 64 to 2048 | Size of the arc-length table (more makes `closest` more exact) |
| `Path` member | What it does |
| --- | --- |
| `length`, `closed`, `points`, `curve` | The path's length in metres, whether it loops, its control points, and three's curve |
| `pointAt(d, target?)` | The point `d` metres along (wrapped on a closed path, clamped on an open one) |
| `tangentAt(d, target?)` | The unit direction of travel there |
| `frameAt(d)` | `{ position, tangent, right }`; `right` lies on the ground plane |
| `tOf(d)` | Metres to 0..1 |
| `sample(count)` | Evenly spaced points |
| `closest(point, hint?, window?)` | `{ distance, t, point, offset, lateral }`: where a point is relative to the path (`lateral` is signed, + right of travel). Pass the last `distance` as `hint` to search near it |
### Things that follow a path
| Export | What it is |
| --- | --- |
| `PathFollower` | Pure state: `new PathFollower(path, options)`, then `step(dt)` each frame; read `distance`, `position(target?)`, `tangent(target?)`; `reset(start?)`; `direction`, `done` |
| `usePathFollower(path, options?)` | A `PathFollower` stepped every frame for you |
| `PathFollow` | Moves its children along a path. `orient`: `'yaw'` (upright, default), `'full'` (flyers), `'none'` |
| `MovingPlatform` | A platform solid for the character controller that moves along a path and carries its riders |
| `CameraDolly` | A camera on rails for title screens and intros |
| `PathMesh` | Draws a path: a ribbon on the ground or a tube in the air |
| `createMovingBox(path, size, options?)` | The moving platform without React (a level box plus a follower) |
`FollowerOptions` (for `PathFollower`, `usePathFollower`, `PathFollow` and `MovingPlatform`):
| Option | Default | What it does |
| --- | --- | --- |
| `speed` | `2` | Metres per second |
| `mode` | `'loop'` | `'loop'` wraps, `'pingpong'` turns around at the ends, `'once'` stops at the end |
| `start` | `0` | Starting distance in metres |
| `pause` | `0` | Seconds to wait at each end in `pingpong` |
| `onEnd` | none | Called when `once` reaches the end, or each time `pingpong` turns |
`MovingPlatform` props: `level` (required; the platform joins `level.boxes` while mounted, flagged `moving` so `LevelMeshes` does not draw it twice), `path` (the top centre's path), `size` (default `[3, 0.4, 3]`), `color` (default `'#8A93B8'`), `riders` (refs to character states it carries), plus `FollowerOptions`. It defaults to ping-pong with a 0.6 s pause.
`CameraDolly` props: `path`, `active` (a boolean or a function read every frame), `speed` (default 1.5 m/s), `lookAt` (a point or a function; default the origin). Render it after the game's camera rig so it wins while active; when it turns off, the rig takes over.
`PathMesh` props: `path`, `kind` (`'ribbon'` default or `'tube'`), `width` (ribbon, default 1), `radius` (tube, default 0.06), `color` (default `'#E8E2D0'`), `lift` (default 0.02 for ribbons, avoids z-fighting with the ground), `emissive` (default 0).
Path movers run at `useFrame` priority `-15`, before default-priority simulations, so a rider is carried before it moves.
## Zones and signals (`zones/`)
### Zones
Trigger areas without the physics module: checkpoints, pickups, kill floors, "you are near the shop". Each update is a cheap overlap test between a few shapes and the actors you pass in (the player's capsule, bots). For physics bodies use `TriggerVolume` instead ([Physics](https://docs.moonspell.app/engine/physics.md)).
```tsx
import { useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { actorOf, useZones, type CharacterState } from '@/engine';
export function useLanterns(spots: { x: number; z: number }[], player: { current: CharacterState }, onLit: (i: number) => void) {
const zones = useZones();
useEffect(() => {
zones.clear();
spots.forEach((spot, i) =>
zones.add({
shape: { kind: 'cylinder', base: { x: spot.x, y: -1, z: spot.z }, radius: 0.8, height: 4 },
once: true,
onEnter: () => onLit(i),
}),
);
}, [zones, spots, onLit]);
useFrame(() => {
zones.update([actorOf('player', player.current, { radius: 0.35, height: 1.7 })]);
});
}
```
`ZoneShape`:
| `kind` | Fields |
| --- | --- |
| `'box'` | `min`, `max` (an axis-aligned box) |
| `'sphere'` | `center`, `radius` |
| `'cylinder'` | `base`, `radius`, `height` (upright, standing on `base`) |
`ZoneDef`: `shape`, `data?` (free data for the handlers), `filter?(actor)`, `once?` (removed after its first enter), `enabled?` (default true), `onEnter?(actor, zone)`, `onExit?(actor, zone)`.
A `ZoneActor` is `{ id, x, y, z, radius?, height? }`: an upright capsule with its feet at `y` (radius and height 0 make it a point). `actorOf(id, state, { radius, height })` builds one from a character state.
| `ZoneSet` member | What it does |
| --- | --- |
| `add(def)` | Returns a `ZoneId` |
| `update(actors)` | Fires `onEnter` and `onExit` and returns the `ZoneEvent[]` (`{ type: 'enter' or 'exit', zone, actor }`) |
| `remove(id)` | Remove a zone; actors inside get no exit |
| `get(id)` | The `Zone` (`id`, `shape`, `data`, `enabled`) |
| `setEnabled(id, enabled)` | Turning it off fires exits for whoever is inside |
| `setShape(id, shape)` | Move or resize; takes effect on the next update |
| `inside(id)` | Actor ids inside now |
| `isInside(actorId, match?)` | Whether an actor is inside any (matching) zone |
| `clear()` | Remove every zone |
| `size` | How many zones |
`useZones()` gives a component its own set, cleared on unmount. `createZones()` makes one without React, and `overlaps(shape, actor)` is the test itself.
### Signals
A typed event bus, so the HUD, audio, particles and game feel react to gameplay without passing callbacks down. Declare the events once as a map from name to payload:
```ts
import { createSignals, juice, useSignal } from '@/engine';
type Events = { lantern: { index: number; left: number }; tagged: { lives: number }; died: void };
export const events = createSignals();
// gameplay
events.emit('lantern', { index: 2, left: 3 });
events.emit('died'); // void events take no payload
// any component
export function useLanternJuice() {
useSignal(events, 'lantern', () => juice('pickup')); // unsubscribes on unmount
}
```
| `Signals` member | What it does |
| --- | --- |
| `emit(name, payload?)` | Call every handler, in subscription order; the payload is type-checked |
| `on(name, handler)` | Subscribe; returns the unsubscribe |
| `once(name, handler)` | For the next emit only |
| `clear(name?)` | Drop the handlers of one event, or of all |
| `count(name?)` | How many handlers |
A handler that throws is reported with an `[engine/signals]` warning and the others still run. `useSignal(signals, name, handler)` subscribes for a component's lifetime; the handler may change every render.
## Debug (`debug/`)
A development readout over the game: fps, average and worst frame time, three's draw calls and triangles (post-processing passes included), the Hermes heap when available, and one line per module (physics, particles, audio, feel) or per game. It is text, so device automation can wait for `fps 60`. Nothing renders in production builds (`__DEV__` is false there).
```tsx
import React, { useEffect } from 'react';
import { EngineCanvas, registerDebugLine } from '@/engine';
const EnemyCount = ({ alive }: { alive: () => number }) => {
useEffect(() => registerDebugLine('enemies', () => `${alive()} alive`), [alive]);
return null;
};
export const Scene = ({ alive }: { alive: () => number }) => (
);
```
| Export | What it does |
| --- | --- |
| `` | `true`, `'top-left'`, `'top-right'` or `'bottom-left'` shows the overlay |
| `DebugOverlay` | The overlay on its own: `position` (default `'top-left'`), `rate` (updates per second, default 4) |
| `registerDebugLine(name, provider)` | Add a line; `provider` returns a string or `null` (skipped). Returns the remover |
| `debugStats` | The engine-wide `DebugStats`; `snapshot()` returns `{ fps, frameMs, maxFrameMs, drawCalls, triangles, heapMB }` |
| `formatFrameLine(stats)` | The first line: `fps 60 · 16.7 ms (max 18) · 42 calls · 12.3k tris · heap 48 MB` |
| `PerfProbe` | The frame timer `EngineCanvas` mounts in development |
The overlay's `testID` is `debug-overlay`. For the HUD's own development row (an AUTO toggle and a telemetry line), see `DebugRow` in [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md).
## Gotchas
- `damp`, not `lerp`, for smoothing in a frame loop: a fixed `lerp` factor runs faster at 120 Hz than at 60 Hz.
- A `Path` must have at least two points, or `createPath` throws.
- `MovingPlatform` and `Model level` change `level.boxes` while mounted. Build a bots' nav grid after they mount, or rebuild it.
- `zones.update` runs where you call it. Call it once per simulation step with every actor that can trigger zones.
- Removing a zone fires no exit; disabling it does.
- The debug overlay and `registerDebugLine` cost a few numbers per frame and nothing when no overlay is mounted, but they are development tools: do not build gameplay on them.
---
# Platform layer
Source: https://docs.moonspell.app/engine/platform-layer
> How one game source runs in a browser, on iOS and on Android, the shell contract (shell.ts, shellModule.ts, shellModule.web.ts), the web boot in src/engine/web, the engine's other platform branches, what happens with no Moonspell app around the game, and why platform differences never go in a game.
A game is written once and plays in three places: a browser, an iOS app and an Android app. The engine carries every difference between them, in a thin layer of platform files and a few `Platform.OS` checks. A game never branches on the platform itself.
This page lists that layer: what it does on each platform, what the app around the game gives it (the shell), and what happens when there is no such app, as in a plain Expo project.
## The rule
What differs per platform belongs in the engine, never in a game:
- Put platform-specific code in `src/engine/`, as a `*.web.ts` file next to the default file or behind a `Platform.OS` check inside an engine module.
- Game code in `src/game/` uses engine APIs that behave the same everywhere: the engine's controls (they answer to a keyboard in a browser), `require()` for assets, `EngineCanvas` or `GameCanvas2D` for drawing, the audio mixer, the UI kit's motion helpers.
- A module that only exists on phones (haptics, sensors) must be optional: the game stays playable without it.
## How files are picked per platform
Metro, through Expo's config, resolves platform extensions: when a web bundle imports `./shellModule`, it takes `shellModule.web.ts` if it exists; the phones take `shellModule.ts`. The same applies to the entry: the template's `package.json` has `"main": "./index"`, so the web build starts from `index.web.ts` and the phones from `index.ts`.
| File | Phones | Browser |
| --- | --- | --- |
| `index.ts` / `index.web.ts` (template root) | `registerRootComponent(App)` | `bootWeb(() => require('./src/App').default)` |
| `src/engine/shellModule.ts` / `.web.ts` | An optional native module | The page that frames the game, over `postMessage` |
| `src/engine/web/boot.ts` | Never loaded | Checks WebGPU, loads Skia's CanvasKit, installs fonts, starts the game |
| `src/engine/web/systemFont.ts` | Never loaded | Answers Skia's `matchFont()` with bundled faces |
Inside engine modules, these branch at runtime:
| Where | What differs |
| --- | --- |
| `input/keyboard.ts` | On the web, `TouchControls`, `Joystick` and `CharacterControls` also listen to the keyboard ([Input](https://docs.moonspell.app/engine/input.md)). On the phones no listener is added |
| `models/assetBytes.ts` | On the web an asset's bytes are fetched from its URL; on the phones they are read from the file through `expo-asset` and `expo-file-system` |
| `ui/Icon.tsx` | iOS draws the SF Symbol; Android and the web draw its Material Symbol twin |
| `ui/motion.ts` | On the web, `rise` and `land` use Reanimated's built-in entrances instead of a `Keyframe` |
| `hud/Gauge.tsx`, `2d/GameCanvas2D.tsx` | `Platform.select` picks a system font family name on iOS |
## The shell contract
The shell is whatever app the game runs inside. On the phones that is the Moonspell app (or its App Clip); in a browser, the Moonspell web app that frames the game in an `