# 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 <cli url> continue <gameId> [dir] --code <code>` | | A game registered on your account that has no repo yet | `npx <cli url> init <dir> --code <code> --no-create`, then in the folder `npx crescent link <gameId>` 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 <command>`; 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 "<title>"` | `moonspell_create_game` | Register this folder as a new game | | `npx crescent link <gameId>` | `moonspell_link_game` | Work on a game you collaborate on, or on a second computer | | `npx crescent continue <gameId>` | `moonspell_continue` | Bring a game made in the app into a new folder | | `npx crescent save -m "<message>"` | `moonspell_save` | Commit and push to the game's private repo | | `npx crescent publish -m "<notes>"` | `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 <name>`. 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 `<canvas>` and uses the browser's `navigator.gpu`. 2. It waits for a layout size, then sets the canvas's backing size to the layout size times `PixelRatio.get()`. 3. It creates an R3F root on that canvas with `createRoot`. The root's renderer is an async factory: `makeWebGPURenderer({ context })` builds a three.js `WebGPURenderer` and awaits `renderer.init()`, so R3F waits for the GPU before the first frame. 4. It wraps the renderer's `render`: run the engine's before-render hooks (camera shake), draw the scene (or let `PostFX` draw it through its pipeline), run the after-render hooks, then call `context.present()` so the frame reaches the screen. 5. Your JSX children are pushed into that root. Every three.js class is a JSX element, because the engine calls `extend(THREE)`. The frame loop is R3F's own (`frameloop: 'always'`). Each frame it calls every `useFrame` callback in priority order, then renders. Engine systems use fixed priorities so they run before your code: game feel at `-20`, path followers at `-15`, vehicle input at `-11`, physics at `-10`, your callbacks at `0`. The full table is on [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md#how-it-works). Two consequences for game code: - Per-frame values live in refs and plain objects, read and written inside `useFrame`. React state is for what the HUD prints, updated on change. - R3F's pointer events are fed by the engine (`render/pointerFeed.ts`), so `onClick`, `onPointerDown` and the other mesh handlers work, one finger at a time, where nothing above the canvas takes the touch. Game input comes from the engine's controls (below). When the game is not on screen (the Moonspell app keeps the next games in its feed loaded and paused), `EngineCanvas` stops the loop and redraws twice a second. Outside the Moonspell app the game is always on screen. ### Other things that share the frame | Piece | Library | How it runs | | --- | --- | --- | | Particles, grass, your own compute | TypeGPU | On three.js's own GPU device (`src/engine/gpu/root.ts`), simulated in compute shaders, drawn by three.js. See [GPU effects](https://docs.moonspell.app/engine/gpu-effects.md) | | Physics | cannon-es | Pure JavaScript, stepped in a `useFrame` at priority `-10`, before your code. See [Physics](https://docs.moonspell.app/engine/physics.md) | | Sound | react-native-audio-api, expo-audio | A mixer with buses and positional sound; it reads the camera through `<AudioListener>`. See [Audio](https://docs.moonspell.app/engine/audio.md) | ## The HUD over the canvas The HUD is not drawn in the 3D scene. It is React Native views placed after the canvas as absolutely positioned siblings, so they sit on top: the top bar, buttons, cards and text. A view that covers the screen uses `pointerEvents="box-none"` so touches that miss its buttons reach the controls under it. Skia draws vector parts of the HUD, such as the speedometer `Gauge`, and the whole of a 2D game: `GameCanvas2D` hosts a Skia canvas, maps the screen onto a stage of logical units, runs a fixed-step loop on `requestAnimationFrame` and draws one Skia picture recorded on the UI thread, with no React re-render per frame. On the phones Skia is native; in a browser it is CanvasKit. See [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md) and [The 2D layer](https://docs.moonspell.app/engine/2d.md). ## How input reaches the game React Native gives a touch to one view at a time, so two `Pressable` buttons cannot be held together. The engine's controls (`TouchControls`, `Joystick`, `CharacterControls`) each track every finger in one react-native-gesture-handler gesture and write numbers into a plain object (`InputState` or `CharacterInput`). The game loop reads that object in `useFrame`. Nothing re-renders per touch. In a desktop browser the same controls also listen to the keyboard (`src/engine/input/keyboard.ts`): WASD and the arrow keys move, Space and a few letters press buttons. A game built on the controls plays with keys without knowing it. A mouse acts as one finger. `GameCanvas2D` has its own pointer events in stage coordinates: taps, drags and swipes, from one gesture handler gesture. See [Input](https://docs.moonspell.app/engine/input.md). ## Where the platform differences live A game never branches on the platform. The engine does, in two ways: - **Platform files.** Metro picks `name.web.ts` over `name.ts` in a browser build: `index.web.ts`, `src/engine/shellModule.web.ts`. The browser-only boot is in `src/engine/web/`. - **Runtime checks.** A few engine modules branch on `Platform.OS`: the keyboard listener (web only), asset bytes (fetched by URL on the web, read from the file on the phones), icons (SF Symbols on iOS, Material Symbols elsewhere), entrance animations on the web. The full list is on [Platform layer](https://docs.moonspell.app/engine/platform-layer.md), and what a player notices on each platform is on [Platform differences](https://docs.moonspell.app/platforms/differences.md). ## The optional shell The shell is whatever app the game runs inside. The engine talks to it through one small contract in `src/engine/shellModule.ts`: a way out (`exitToShell`), whether the game is on screen, a "closed" event, and the links the game was opened with. Every field is optional. | Where the game runs | What the shell is | | --- | --- | | The Moonspell app on iOS or Android | A native module compiled into the app (`requireOptionalNativeModule('GenUXExpoModule')`) | | The Moonspell web app | The page that frames the game in an `<iframe>`, over `postMessage` (`shellModule.web.ts`) | | Your own Expo build, or a browser tab | Nothing. The module is absent, the game is always active, and exit only does what your `onExit` does | So the engine runs the same with or without the Moonspell app around it. The app itself is private; see [What is open and what is private](https://docs.moonspell.app/start/open-source.md). ## Glossary | Term | Meaning | | --- | --- | | **The engine** | `src/engine/` in a game repo: the canvas, controls, HUD kit, audio, physics, 2D layer and the other pieces a game imports from `@/engine`. Each game carries its own copy | | **The template** | `templates/crescent-game/`, the folder every game repo starts as: the engine, the starter game, the examples, and the instructions and skills for coding agents | | **Game** | What `src/game/index.tsx` default-exports: a `GameDefinition` whose `Component` is the full-screen game | | **Engine piece** | One folder of the engine with its own API, such as `input/` or `physics/` | | **Genre module** | A whole game from one config: `defineRaceGame`, `definePuzzleGame`, `defineShooterGame`, `defineKingdomGame` in `src/engine/modules/` | | **Example** | A complete game in `examples/<name>/index.tsx`, run by copying it over `src/game/index.tsx` | | **Shell** | The app around a game. In the Moonspell app it gives the game its exit, its pause state and its links; outside it there is none | | **Game backend** | The game's own Cloudflare Worker for leaderboards, rooms and saves, from the scaffold in the `realtime-backend` skill. Its URL is `GAME_BACKEND_URL` in `src/engine/net/config.ts`; `null` keeps everything on the device | | **Static bundle** | An exported version of a game: for the web, the `dist/` folder from `npm run export:web` | | **The Moonspell app** | The hosted product: an iOS, Android and web app where players chat with Nova, whose coding agent builds games on this engine | | **`crescent`** | The internal code name of the engine and template. It appears in folder names, package names and identifiers | --- # Project structure Source: https://docs.moonspell.app/start/project-structure > What is in a Moonspell game repo, as an annotated tree of the game template, which files you edit, which you leave alone, the placeholders a copied template must replace, and where the docs and skills for coding agents live. Every Moonspell game is its own repo, and every repo starts as a copy of the game template, `templates/crescent-game/`. This page is a map of that folder: what each file is for, what you change to make a game, and what you rename when you copy it. ## The tree ```text templates/crescent-game/ package.json scripts and the pinned dependency set; "main": "./index" package-lock.json app.json Expo config: name, slug, orientation, iOS and Android ids, plugins index.ts phone entry: registerRootComponent(App) index.web.ts browser entry: bootWeb, then the same App metro.config.js three -> its WebGPU build, R3F -> its web build, .glb as assets babel.config.js babel-preset-expo + the TypeGPU plugin ('use gpu' -> WGSL) tsconfig.json strict; @/* -> src/*; includes examples/ .gitignore node_modules, dist, .expo, public/canvaskit.wasm, ... playtest.json a scripted run of the game with real touches CLAUDE.md standing instructions for Claude Code AGENTS.md the same for other coding agents (an exact copy) .claude/ settings.json Claude Code permissions for the repo's checks skills/ 24 skills, one folder each with a SKILL.md realtime-backend/ scaffold/backend/ the game's Cloudflare Worker, copied to backend/ when it goes online docs/ agent-facing references: engine-api.md, games-contract.md, one file per module and engine piece, cloudflare.md src/ App.tsx mounts the game, wires exit and close, shows "Play" after an exit theme.ts colours, radii and spacing used by the HUD chrome game/ index.tsx THE GAME: default-exports a GameDefinition (starter: Sunset Sprint) engine/ the engine, this game's own copy index.ts everything @/engine exports render/ core/ input/ hud/ ui/ audio/ physics/ models/ gpu/ feel/ 2d/ character/ level/ combat/ bots/ save/ net/ story/ turns/ paths/ zones/ debug/ web/ modules/ racing/ puzzle/ shooter/ kingdom/ shell.ts shellModule.ts shellModule.web.ts math.ts examples/ eleven complete games, one index.tsx each, plus README.md assets/ icon.png audio/ Sunset Sprint's engine loops and music, Ashvale's sounds sfx/ short effects (pop.wav, the puzzle module's sounds) models/ .glb models (KayKit's CC0 adventurers, Ashvale's buildings) sprites/ sprite sheets for the examples ``` Folders that appear when you run things, all ignored by git: `node_modules/`, `dist/` (from `npm run export:web`), `public/canvaskit.wasm` (copied by `npm run web` and `npm run export:web`), `.expo/`, and `ios/` and `android/` if you run `npm run ios` or `npm run android`. Every folder of `src/engine/` has its own page; the map with one line per folder is the [engine overview](https://docs.moonspell.app/engine/overview.md). ## What you edit | File | What you change | | --- | --- | | `src/game/index.tsx` | The game. Start from the starter, an example, or an empty file ([Your first game](https://docs.moonspell.app/start/first-game.md)). Split it into more files in `src/game/` when it grows | | `assets/` | Sounds, models, images and fonts, loaded with `require()`. Keep the total small: players download every version | | `app.json` | `expo.name` (the window and app name, "Sunset Sprint" in the template), `expo.slug`, `expo.ios.bundleIdentifier` and `expo.android.package` | | `src/theme.ts` | The colours the HUD chrome (`TopBar`, `FinishCard`, `RoundButton`) uses | | `src/engine/net/config.ts` | `GAME_BACKEND_URL` once you deploy a backend ([Backend](https://docs.moonspell.app/backend/overview.md)), and `GAME_ID` (below) | | `GDD.md` | The game's design document. It is not in the template: you or your agent create it with the first change ([GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md)) | | `playtest.json` | The scripted test. The template's drives Sunset Sprint, so rewrite it when your controls or labels differ | The examples can go once you have picked a starting point; nothing imports them. Deleting `examples/` also removes them from `npm run typecheck`. ## What you normally leave alone | File | Why | | --- | --- | | `src/engine/` | It is a library that happens to live in your repo. Change it when your game needs something the engine lacks, or to fix a bug, and keep its exports working. A platform difference goes here, never in the game ([Platform layer](https://docs.moonspell.app/engine/platform-layer.md)) | | `index.ts`, `index.web.ts`, `src/App.tsx` | The entry chain. `index.web.ts` must keep loading CanvasKit before the game; keep `"main": "./index"` in `package.json` | | `metro.config.js`, `babel.config.js` | They make `three` the WebGPU build and compile TypeGPU shaders. Without them the engine does not build | | Dependency versions in `package.json` | They are pinned to what the Moonspell app compiles in. See [The game contract](https://docs.moonspell.app/start/game-contract.md#dependencies-and-native-code) | | `"crescent"` block in `package.json` | Metadata for the Moonspell pipeline. Harmless outside it | Each game owns its copy of the engine. Inside the Moonspell app, engine files a game never changed are refreshed from the template on the game's next build. In your own repo nothing refreshes them: copy engine changes from a newer template yourself. ## Placeholders to replace The Moonspell pipeline fills these in when it creates a game repo. When you copy the template yourself, replace them: | Placeholder | Where | Replace with | | --- | --- | --- | | `__GAME_SLUG__` | `name` in `package.json`, twice in `package-lock.json`, `GAME_ID` in `src/engine/net/config.ts` | Your game's slug, such as `orb-hunt` | | `__GAME_TITLE__` | The first line of `CLAUDE.md` and `AGENTS.md` | Your game's title | | `__PREFIX__`, `__D1_DATABASE_ID__` | `.claude/skills/realtime-backend/scaffold/backend/wrangler.jsonc` | Nothing yet. The `realtime-backend` skill fills them in its copy in `backend/` when you set up the backend | `GAME_ID` matters more than it looks: it names the game's on-device storage (saves, settings, local leaderboards). Two games with the same id on one phone, or on one web origin, share their saves ([Saves](https://docs.moonspell.app/engine/saves.md)). Also change the ids in `app.json` (`com.rofi.crescentmoon.game` in the template) to your own before you build for a phone. A quick way to start a new game from a clone of the repository: ```bash cp -R moonspell/templates/crescent-game orb-hunt cd orb-hunt git init npm install ``` Then replace the placeholders above and commit once, so every later change is a diff you can read. ## Docs and skills for agents A game repo carries its own instructions, so a coding agent opened in it knows the engine without these docs: | Where | What | | --- | --- | | `CLAUDE.md` | What can be built and with which engine piece, the rules every game keeps, how to finish a task. Its section "Outside the Moonspell app" says which commands to use without the Moonspell CLI | | `AGENTS.md` | The same instructions for agents that look for that name. It is an exact copy of `CLAUDE.md` | | `.claude/skills/` | 24 skills: `game-router` first, then one per genre, engine piece, art, audio, backend and the playtest | | `docs/` | Short references the skills point to for every field and range | | `examples/README.md` | What each example shows and which route uses it | Some skills and some lines in `CLAUDE.md` assume the Moonspell pipeline (the `crescent` CLI, generated art, live push to the app). [Build with AI](https://docs.moonspell.app/ai/overview.md) and [Skills](https://docs.moonspell.app/ai/skills.md) say which, and what to do instead. --- # The game contract Source: https://docs.moonspell.app/start/game-contract > The rules every Moonspell game follows so the same source plays in a browser, on iOS, on Android and inside the Moonspell app, covering the entry export, exit and cleanup, pausing, orientation, input, assets, drawing, the pinned dependency set, performance budgets and the labels playtests rely on. A Moonspell game is written once and plays in a browser, on iOS and on Android, and the same source can run inside the Moonspell app. That works because every game keeps a short list of rules. Most of them are things the engine already does for you if you use its pieces; this page says what they are and why. The rules come from the template's `CLAUDE.md` and `docs/games-contract.md`, which coding agents read. They are written here for a person. ## The entry - `src/game/index.tsx` default-exports a `GameDefinition`: `id`, `title`, `description`, an optional `badge` and `accent`, and `Component`. Genre modules (`defineRaceGame` and the others) return one for you. - `src/App.tsx` renders `game.Component` with `onExit`. Keep that file and that contract. - `index.ts` is the phone entry and `index.web.ts` the browser entry. Do not edit or delete `index.web.ts`: it loads Skia's CanvasKit before the game. Keep `"main": "./index"` in `package.json`. ## Exit, close and clean up - **Show a way out.** Every game has an exit or pause button that calls `onExit`. `TopBar` and `FinishCard` have one built in. - **Unmounting stops the game.** `App.tsx` unmounts the game when the player exits, and when the Moonspell app closes it. Make every effect clean up on unmount: audio players (`remove()`), timers, subscriptions, sockets. Anything left running keeps playing after the game is gone. The engine's own pieces (the canvas, the audio mixer, the 2D loop) already clean up. - **Outside the Moonspell app**, exiting unmounts the game and `App.tsx` shows a "Play" button; in a browser it also goes back one page when there is one. If your game is the whole app, make the exit useful there (a menu, a restart) or hide it. ## Pausing The Moonspell app keeps the next games in its feed loaded and paused until the player opens one. The engine pauses itself: `EngineCanvas` stops its frame loop, `GameCanvas2D` stops stepping, the audio mixer goes quiet. Only code that runs outside those loops (your own `setInterval`, a sound outside the mixer) needs to check `useShellActive()` from `@/engine` and pause when it is `false`. Outside the Moonspell app the game is always active. See [Platform layer](https://docs.moonspell.app/engine/platform-layer.md#the-shell-contract). ## Orientation and layout - Portrait by default. The template's `app.json` sets `"orientation": "portrait"`. - Keep controls inside the safe area and within reach of the thumbs. Make text big enough to read at arm's length. - On the web, a plain export fills the browser window. A size change resizes the 3D renderer and camera in place ([Platform differences](https://docs.moonspell.app/platforms/differences.md)). ## Input: touch first, keyboard on the web - Take input through the engine's controls (`TouchControls`, `Joystick`, `CharacterControls`), `GameCanvas2D`'s pointer events, or react-native-gesture-handler. The engine's controls handle multi-touch, and in a desktop browser they also answer to the keyboard. A hand-rolled touch handler does neither. See [Input](https://docs.moonspell.app/engine/input.md). - Do not use `Pressable` for anything held. React Native gives a touch to one view at a time, so a player could not steer while holding gas. - Mesh pointer events (`onClick`, `onPointerDown` and the rest) work on every platform, one finger at a time, only where nothing above the canvas takes the touch. Game controls still come from the engine's controls ([Input](https://docs.moonspell.app/engine/input.md)). - Two-finger gestures belong to the game on the phones. In the Moonspell web app, a pinch where both fingers move toward each other closes the game. ## Assets and the bundle - Put files in `assets/` and load them with `require()` and the engine's loaders (`<Model>`, `loadGlb`, `<Billboard>`, the audio mixer, `useGameFonts`). Never read a file by path or build a URL by hand: a web build names its assets by address, a phone build by module id. - No `import()`. The web build is one file; use `require()` or static imports. - Keep assets small. Inside the Moonspell app players download every version. ## Drawing - 3D goes through `EngineCanvas`; GPU code through TypeGPU on three.js's device. There is no WebGL path on any platform, and a browser without WebGPU shows a notice instead of the game. - `three` is always the WebGPU build. Import the renderer and TSL from `three/webgpu` and `three/tsl`. - Skia text uses `matchFont()` or a bundled face with `useFont`. In a browser `matchFont` answers with the engine's Nunito, so do not rely on a system font's exact metrics. - For entrance animations use `rise`, `land` and `leave` from `@/engine/ui`, or Reanimated's built-in entrances (`FadeIn`, `FadeInDown`). Never pass a `new Keyframe(...)` to `entering`: in a browser it pins the element out of its layout. ## Platform differences stay in the engine A game never branches on the platform. What differs goes in `src/engine/`, as a `*.web.ts` file or a `Platform.OS` check ([Platform layer](https://docs.moonspell.app/engine/platform-layer.md)). A module that only exists on phones (haptics, sensors, the keyboard controller) must be optional: guard it with `Platform.OS !== 'web'` or use the engine's wrapper (`haptics` from `@/engine` is a no-op where haptics do not exist), and never let the game depend on it to be playable. ## Dependencies and native code The template's `package.json` lists every native module the Moonspell app compiles in, pinned to the exact versions it ships: Expo SDK 58, React Native 0.88.1, react-native-webgpu, Skia, Reanimated, gesture handler, the audio libraries and the rest. A game inside the Moonspell app is only JavaScript and assets, loaded into that prebuilt app, so it can never add native code: a package the app does not contain crashes the moment it loads. What that means for you: | If your game | You may | | --- | --- | | Should also run inside the Moonspell app | Add pure JavaScript packages (`npm install --save-exact <pkg>`). Do not add native packages, do not change the versions of `react`, `react-native`, `expo` or any native module, and do not run `npx expo install --fix` | | Runs only in your own builds | Also add native packages. Each one changes your own development build: rebuild with `npm run ios` or `npm run android`, and check the package supports the web, or guard it there. The game then no longer runs inside the Moonspell app | `npx expo-doctor` reports the pinned versions as not matching the SDK's recommended patch versions. That is expected; leave them unless you have left the Moonspell app's dependency set on purpose. ## Performance budgets Phones are the budget, not your laptop. Numbers the engine is measured against: | Area | Budget | | --- | --- | | 2D layer | 64 tiles plus a 200-particle burst well under 8 ms of JavaScript per frame | | Physics (cannon-es, JavaScript) | Under about 60 bodies in a level, about 30 awake at once. 200 toppling boxes drop a game to about 12 fps | | GPU debris | A few hundred pieces for a crash, 1 to 5 thousand for weather; one draw call each | | GPU particles | A few hundred for a local effect, 1 to 3 thousand for weather; one draw call each | | Rendering | Shadow maps at 1024 on phones, `PostFX` at 0.75 resolution, ambient occlusion off | Keep per-frame values in refs, never in React state, and use `<EngineCanvas debug>` in development to watch fps and draw calls. Details: [Physics](https://docs.moonspell.app/engine/physics.md), [GPU effects](https://docs.moonspell.app/engine/gpu-effects.md), [The 2D layer](https://docs.moonspell.app/engine/2d.md), [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md). ## Labels for playtests and screen readers A playtest (`playtest.json`) plays the game with real touches and checks what it sees. It finds things by `accessibilityLabel` or `testID`. Text drawn in a Skia or WebGPU canvas is invisible to it, so give every control a test presses and every number it checks a label or `testID` on a React Native view: ```tsx <Text testID="hud-score" accessibilityLabel={`Score ${score}`}>{score}</Text> ``` The engine's pieces already have them: `TopBar`'s exit is `btn-exit` ("Exit game") and its status line `hud-status`, `FinishCard`'s buttons are `btn-restart` and `btn-finish-exit` ("Back to games"), the racing controls are labelled "Accelerate", "Brake", "Steer left" and "Steer right", and the joystick is "Move joystick". Exports keep these labels, so they also make the game usable with VoiceOver and TalkBack. Update `playtest.json` in the same change whenever screens, labels or controls change. See [GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md). ## Development-only UI Debug UI behind `__DEV__` (a telemetry line, an AUTO button that plays the game) is welcome; static exports are production builds and never show it. `<EngineCanvas debug>` adds the engine's readout (fps, draw calls, physics, particles), and `registerDebugLine` adds your own lines. ## House style Games made in the Moonspell app also follow a few style rules. They are not needed for a game to run, but the skills and examples assume them: - No emoji on screen: icons are SF Symbols through `<Icon>` from `@/engine/ui` (Material Symbols on Android and the web). - One font family per game, picked by genre, for every text. - A title screen, a HUD, a pause menu and a results screen in the game's own look. The **game-ui** skill has the details ([Skills](https://docs.moonspell.app/ai/skills.md)). ## Check before you ship ```bash npm run typecheck # Metro does not stop on type errors npm test # the engine's unit tests npm run export:web # the build players get must succeed ``` --- # What is open and what is private Source: https://docs.moonspell.app/start/open-source > Which parts of Moonspell are open source under the MIT License, whose source stays private (the Moonspell phone app and the crescent CLI, which is still handed out in built form), and the two ways to build a game, engine only or published to the Moonspell app. Moonspell is open source under the MIT License, with two exceptions whose source is private: the Moonspell phone app and the `crescent` CLI. The CLI is still handed out, built, to developers who publish games to the Moonspell app. This page draws that line once, so the other pages do not have to. ## The line **Open (MIT):** the engine, the game template, the examples, the skills, the game backend scaffold, the server (Nova, the worker runs, the app API, the web app), the edge Worker, the Moonspell MCP server, the contracts, the specs and these docs. **Private source:** the Moonspell phone app (iOS, Android and the App Clip) and the `crescent` CLI. **Built and handed out:** the CLI, as a package that moonspell.app serves together with a starter folder, for [local developer mode](https://docs.moonspell.app/start/publish-to-moonspell.md). You can install and run it; its source is not in the repository. ## What is open Everything in the public repository is under the MIT License (the `LICENSE` file at the root): | Part | Where | | --- | --- | | The engine and the game template | `templates/crescent-game/` (the engine is `src/engine/`) | | The eleven example games | `templates/crescent-game/examples/` | | The skills and agent docs | `templates/crescent-game/.claude/skills/`, `CLAUDE.md`, `AGENTS.md`, `docs/` | | The game backend scaffold | `templates/crescent-game/.claude/skills/realtime-backend/scaffold/backend/` | | The starter overlay for local developer mode | `templates/crescent-game-local/` and `scripts/pack-starter.mts` | | The server: Nova, the worker runs, the app API, the web app, the developer routes | the repository root: `agent/`, `src/`, `db/` | | The edge Worker | `edge/` | | The Moonspell MCP server | `mcp/` | | Contracts and feature specs | `contracts/`, `spec/` | | These docs and the site that renders them | `docs/`, `docs-site/` | You may use, change and ship any of it, including in commercial games, under the terms of the MIT License. ## What is private 1. **The Moonspell app**: the native iOS and Android app (and its App Clip) that hosts the feed, the chat with Nova and the player. 2. **The `crescent` CLI's source**: the tool that turns a game into the static bundle the Moonspell app loads, and that signs a developer in, saves and publishes in local developer mode. These docs do not describe how either works inside: not the bundle format, not how the app loads a game, not the live-update protocol. Where a page mentions them, it says only what they do for a game from the outside. ## Two ways to build a game ### 1. Engine only, with plain Expo Clone the repository and make the template your own. You need no account and nothing from Moonspell. - `npm run web` runs a game in the browser, `npm run export:web` makes a static web build, and `npm run ios` and `npm run android` make a normal Expo development build. See [Quickstart](https://docs.moonspell.app/start/quickstart.md). - On the phones, the engine looks for the Moonspell app's native module and carries on without it. The game is always active, and exit only does what your `onExit` does ([How it works](https://docs.moonspell.app/start/how-it-works.md#the-optional-shell)). - The game is an Expo app you own. You host it, ship it to the stores, or keep it to yourself. The template's `crescent` scripts (`npm start`, `npm run export`, `npm run push`, `npm run doctor`) need the CLI and fail in a plain clone. [Claude Code and other agents](https://docs.moonspell.app/ai/claude-code.md#what-assumes-the-moonspell-app) lists the skills that assume it and what to do instead. ### 2. Publish to the Moonspell app, with local developer mode Build the game on your own computer with your own coding agent, and publish it to the Moonspell app on iPhone and Android and to the web. - You get a starter folder and the built `crescent` CLI from moonspell.app (one `npx` command). - You sign in with a Moonspell account (Google). - The CLI saves your work to the game's private repo on Moonspell and publishes versions, which are public at once: in the feed, at a share link, open to community builds. - Your agent can use the Moonspell MCP server for the engine's skills and your account. The steps are on [Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md). The engine is the same in both: the starter is this template plus a small overlay of agent instructions and settings. ## How the hosted product relates to the engine The Moonspell app is one user of the engine. Players describe a game in a chat; Nova, the chat agent, starts a worker run in which a coding agent edits that game's own copy of the template; the run exports the game and the app plays the new version. The engine in such a game is the same `src/engine/` you get from the public template. [Architecture](https://docs.moonspell.app/moonspell/architecture.md) explains the whole system, and the rest of the "How Moonspell works" section covers each part. **The hosted pipeline cannot run end to end from a public clone.** The server, Nova and the edge Worker run locally, but a worker run stops at the step that exports the game, because that step builds the CLI from its source. Anything that needs the phone app (testing the iOS or Android apps, App Clips) is out of reach too. [Development setup](https://docs.moonspell.app/moonspell/dev-setup.md) lists what works and what does not. ## Contributing Contributions to every public part are welcome: the engine, the modules, the examples, the skills, the server, the edge Worker, the MCP servers and these docs. [Contributing](https://docs.moonspell.app/contributing.md) has the steps, the checks to run and the repository map. ## Third-party assets The template bundles a few assets made by others (for example KayKit's CC0 adventurer models in `assets/models/kaykit/`). Their licences and credits are listed in the repository's `THIRD_PARTY_NOTICES.md`. --- # Engine overview Source: https://docs.moonspell.app/engine/overview > The map of src/engine, what each folder and file does, how a game imports from the engine, and what belongs in src/game versus src/engine. The engine is the folder `src/engine/` inside every game repo made from the template (`templates/crescent-game/`). It is plain TypeScript and React Native: a game imports what it needs from `@/engine` and composes it in `src/game/`. There is no npm package; each game carries its own copy of the engine. This page is the map. Each folder has its own page with examples and reference tables. ## How a game imports from the engine `@/` is a path alias for `src/` (set in the template's `tsconfig.json`), so `@/engine` is `src/engine/index.ts`. Almost everything comes from there: ```tsx import React from 'react'; import { View } from 'react-native'; import { EngineCanvas, Environment, TopBar, type GameDefinition, type GameScreenProps } from '@/engine'; const Screen = ({ onExit }: GameScreenProps) => ( <View style={{ flex: 1 }}> <EngineCanvas camera={{ position: [0, 3, 8] }}> <Environment preset="day" /> <mesh position={[0, 1, 0]} castShadow> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="#E8ECF4" /> </mesh> </EngineCanvas> <View style={{ position: 'absolute', left: 0, right: 0, top: 0 }} pointerEvents="box-none"> <TopBar title="My game" status="Ready" onExit={onExit} /> </View> </View> ); const game: GameDefinition = { id: 'my-game', title: 'My game', description: 'A box in the sun.', Component: Screen, }; export default game; ``` Three other entry points are imported by path, not from `@/engine`: | Import | What it is | Page | | --- | --- | --- | | `@/engine/ui` | The game UI kit: `Icon`, `Counter`, `PressScale`, motion helpers, `SheetImage` | [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md) | | `@/engine/ui/fonts/<face>` | One bundled font face per module (`@/engine/ui/fonts/nunitoBlack`) | [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md) | | `@/engine/modules/<name>` | A whole genre from a config: `racing`, `shooter`, `puzzle`, `kingdom` | [Game modules](https://docs.moonspell.app/modules/overview.md) | Fonts and modules stay out of `@/engine` on purpose: a game's bundle carries only the faces and modules it imports. ## The map of `src/engine` ### Top-level files | File | What it does | Page | | --- | --- | --- | | `index.ts` | The public API: everything `@/engine` exports | this page | | `math.ts` | `clamp`, `lerp`, `damp`, `angleDelta`, `TAU`, `formatTime`, `makeRng` | [Utilities](https://docs.moonspell.app/engine/utilities.md) | | `shell.ts` | `exitToShell`, `onShellClosed`, `isShellActive`, `onShellActive`, `useShellActive`: the game's side of the app around it | [Platform layer](https://docs.moonspell.app/engine/platform-layer.md) | | `shellModule.ts` | What the app gives the game on phones (an optional native module, absent in plain Expo) | [Platform layer](https://docs.moonspell.app/engine/platform-layer.md) | | `shellModule.web.ts` | The same contract in a browser, over `postMessage` with the page that frames the game | [Platform layer](https://docs.moonspell.app/engine/platform-layer.md) | ### Folders | Folder | What it does | Page | | --- | --- | --- | | `render/` | `EngineCanvas` (the 3D surface), the WebGPU renderer, `Environment` (sky, sun, fog), `PostFX` (bloom, grade), `glow`, `Water` | [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md) | | `core/` | `GameDefinition`, `GameScreenProps` and `EngineErrorBoundary` | [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md) | | `input/` | `TouchControls` (racing), `Joystick`, `CharacterControls`, input state objects, the web keyboard | [Input](https://docs.moonspell.app/engine/input.md) | | `hud/` | `Gauge` (a Skia speedometer) and HUD chrome: `TopBar`, `RoundButton`, `FinishCard`, `Banner`, `DebugRow` | [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md) | | `ui/` | The game UI kit (`@/engine/ui`): SF Symbol icons, bundled fonts, rolling counters, press and pop motion | [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md) | | `audio/` | `AudioMixer` and `useAudioMixer` (buses, positional sound, ducking, music), `RaceAudio` | [Audio](https://docs.moonspell.app/engine/audio.md) | | `physics/` | Rigid bodies on cannon-es, triggers, joints, rays, `Vehicle`, GPU `Debris` | [Physics](https://docs.moonspell.app/engine/physics.md) | | `models/` | `Model` (bundled `.glb` with a fallback), `Scenery` (one model placed many times), `Billboard`, animation graph and IK | [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md) | | `gpu/` | TypeGPU on three's device: `Particles`, `Trail`, `Grass`, `LivelyGround`, `GpuStrip`, the root for your own compute | [GPU effects](https://docs.moonspell.app/engine/gpu-effects.md) | | `feel/` | Camera shake, hit-stop, slow motion, flash, squash and stretch, tweens, `juice` presets | [Game feel](https://docs.moonspell.app/engine/game-feel.md) | | `2d/` | `GameCanvas2D` and the 2D kit: stage units, pointer, loop, sprites, tile maps, platformer bodies, haptics, one-shot sounds | [The 2D layer](https://docs.moonspell.app/engine/2d.md) | | `character/` | The character controller, cameras, `CharacterMesh`, ripples and flocks | [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md) | | `level/` | Levels of boxes, pieces (ramps, stairs, arches), grid maps, `LevelMeshes`, `Extruded`, `Lathed` | [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md) | | `combat/` | Health, weapons, hitscan, projectiles, aim assist, statuses, combat HUD | [Combat](https://docs.moonspell.app/engine/combat.md) | | `bots/` | Nav grid, pathfinding, senses, steering, `BotSquad`, roster fill | [Bots](https://docs.moonspell.app/engine/bots.md) | | `save/` | Versioned saves, progression, shop, settings, share card | [Saves](https://docs.moonspell.app/engine/saves.md) | | `net/` | Leaderboards, presence, rooms, launch links, the game backend URL | [Net](https://docs.moonspell.app/engine/net.md) | | `story/` | Dialogue with portraits, choices, objectives, chapters | [Story](https://docs.moonspell.app/engine/story.md) | | `turns/` | `TurnRules`, chess, search, `useOpponent`, `useTurnRoom` | [Turns](https://docs.moonspell.app/engine/turns.md) | | `paths/` | Curves in metres, followers, moving platforms, camera dollies, path meshes | [Utilities](https://docs.moonspell.app/engine/utilities.md) | | `zones/` | Trigger zones without physics, and typed signals (an event bus) | [Utilities](https://docs.moonspell.app/engine/utilities.md) | | `debug/` | The development readout: fps, frame time, draw calls, module lines | [Utilities](https://docs.moonspell.app/engine/utilities.md) | | `web/` | The browser boot (`bootWeb`) and Skia's font fallback for CanvasKit | [Platform layer](https://docs.moonspell.app/engine/platform-layer.md) | | `modules/` | Genre modules: `racing`, `shooter`, `puzzle`, `kingdom` | [Game modules](https://docs.moonspell.app/modules/overview.md) | ## What goes in `src/game` and what goes in `src/engine` The entry chain is fixed: `index.ts` (phones) or `index.web.ts` (browser) registers `src/App.tsx`, and `App.tsx` renders the `Component` of the `GameDefinition` that `src/game/index.tsx` default-exports. Keep that contract. Put in `src/game/`: - Everything about this game: its rules, level data, palette, fonts, HUD screens, sounds list and the `GameDefinition`. - Code that only this game needs, even if it is large. Change `src/engine/` only when: - The game needs something the engine does not have and it would be useful to any game (a new control scheme, a new effect). Add it as a new export and keep existing exports working. - Something differs per platform. Platform differences live in the engine (`*.web.ts` files, `Platform.OS` checks), never in a game. See [Platform layer](https://docs.moonspell.app/engine/platform-layer.md). Rules that keep a game working everywhere: - Read per-frame values from mutable objects inside `useFrame` (input state, character state), not from React state. React state is for things that change a few times a second at most. - Take input through the engine's controls or react-native-gesture-handler, so a keyboard and mouse work in a desktop browser too. - Load every asset with `require()` and the engine's loaders. Do not build file paths or URLs by hand. - Draw 3D through `EngineCanvas`. There is no WebGL path. ## Gotchas - `src/engine/index.ts` re-exports whole folders with `export *`. Two folders exporting the same name would clash, so engine additions use distinct names. - Inside the hosted Moonspell app, the template's engine files are refreshed in existing games when a new version runs, unless the game changed them. In your own repo nothing refreshes them: copy engine changes from the template yourself. - Tests for the pure parts of the engine run with `npm test` (Node's test runner over `src/engine/**/*.test.ts`). Type errors do not stop Metro, so also run `npm run typecheck`. --- # Canvas and rendering Source: https://docs.moonspell.app/engine/canvas-and-rendering > EngineCanvas, the three.js WebGPU renderer it drives, how react-three-fiber runs inside it, the frame loop, Environment for sky and light, PostFX, glow and the error boundary. `EngineCanvas` is the engine's 3D surface. It puts a WebGPU canvas on screen, creates a three.js `WebGPURenderer` on it and runs a react-three-fiber (R3F) root inside, so everything you place in it uses the normal R3F API: JSX meshes, `useFrame`, `useThree`. Use it for any 3D game. A flat game uses `GameCanvas2D` instead ([The 2D layer](https://docs.moonspell.app/engine/2d.md)). The code lives in `src/engine/render/` and `src/engine/core/`. ## Smallest working example A spinning box in the sun, with the engine's sky, shadows and bloom: ```tsx import React, { useRef } from 'react'; import { View } from 'react-native'; import { useFrame } from '@react-three/fiber'; import type * as THREE from 'three'; import { EngineCanvas, EngineErrorBoundary, Environment, PostFX, type GameDefinition, type GameScreenProps } from '@/engine'; const SpinningBox = () => { const mesh = useRef<THREE.Mesh>(null); useFrame((_, delta) => { if (mesh.current) mesh.current.rotation.y += delta; }); return ( <mesh ref={mesh} position={[0, 1, 0]} castShadow> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="#E8ECF4" /> </mesh> ); }; const Screen = (_props: GameScreenProps) => ( <View style={{ flex: 1 }}> <EngineErrorBoundary label="Renderer"> <EngineCanvas camera={{ position: [0, 3, 6], fov: 50 }}> <Environment preset="sunset" /> <PostFX preset="glow" /> <SpinningBox /> <mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow> <planeGeometry args={[40, 40]} /> <meshStandardMaterial color="#3A4A3A" /> </mesh> </EngineCanvas> </EngineErrorBoundary> </View> ); const game: GameDefinition = { id: 'spin', title: 'Spin', description: 'A box that spins.', Component: Screen }; export default game; ``` Put HUD and controls next to the canvas as React Native views (absolutely positioned on top), not inside it. Everything inside `EngineCanvas` is the 3D scene. ## How it works 1. `EngineCanvas` renders a `Canvas` from `react-native-webgpu` (Dawn on the phones) and measures it. 2. Once it has a size, it sets the backing size to layout size times `PixelRatio.get()` and creates an R3F root with `createRoot` from `@react-three/fiber`, once. A later size change (a browser window, split screen, a foldable, rotation) reconfigures the same root with the new size: R3F resizes the renderer and the camera's aspect, and the renderer, scene and camera are kept. 3. The root's `gl` is an async factory that calls `makeWebGPURenderer({ context })` and awaits `renderer.init()`, so R3F waits for the GPU before the first frame. 4. The renderer's `render` is wrapped: it runs the engine's before-render hooks (the camera shake, the debug probe), draws (or runs the post-processing override), runs the after-render hooks, then calls `context.present()` so the frame reaches the screen. 5. New `children` are pushed into the existing root. The root is rebuilt only when the canvas size changes. The frame loop is R3F's own (`frameloop: 'always'`). Every `useFrame` callback runs once per frame, in priority order (lower first). The engine uses a few fixed priorities so systems run in a sensible order: | Priority | Who | | --- | --- | | `-100` | The debug frame timer (development only) | | `-20` | `GameFeel`: tweens and the shake | | `-15` | Path followers and moving platforms | | `-11` | `Vehicle` input | | `-10` | `Physics` steps the world | | `0` | Your `useFrame` callbacks (the default) | When `GameFeel` is mounted, the `delta` every `useFrame` receives is scaled by slow motion and hit-stop ([Game feel](https://docs.moonspell.app/engine/game-feel.md)). ### Why `three` is the WebGPU build The template's `metro.config.js` resolves `three` and `three/webgpu` 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. Import the renderer and TSL from their entry points: ```ts import { WebGPURenderer, MeshStandardNodeMaterial } from 'three/webgpu'; import { uniform, positionWorld } from 'three/tsl'; ``` The same config points `@react-three/fiber` at its web build (`dist/react-three-fiber.esm.js`): the package's React Native entry targets expo-gl, which the engine does not use. `EngineCanvas` drives the reconciler itself. It also calls `extend(THREE)`, so every three class is a JSX element. There is no WebGL fallback on any platform. A browser without WebGPU gets a message instead of the game ([Platform layer](https://docs.moonspell.app/engine/platform-layer.md)). ## `EngineCanvas` | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `children` | `React.ReactNode` | required | The R3F scene | | `camera` | `{ position?, fov?, near?, far? }` | `fov: 55`, `near: 0.1`, `far: 600`, `position: [0, 4, 10]` | The default perspective camera. Your values are merged over the defaults | | `background` | `string` | none | A flat scene background colour. `Environment` draws a sky, so you rarely need it | | `style` | `ViewProps['style']` | `flex: 1` | Style of the wrapping view | | `onReady` | `() => void` | none | Called once the renderer is ready and the first frame can be drawn | | `testID` | `string` | none | On the wrapping view | | `debug` | `boolean`, `'top-left'`, `'top-right'` or `'bottom-left'` | off | Shows the development readout over the canvas. Never shown in production builds ([Utilities](https://docs.moonspell.app/engine/utilities.md)) | Use one `EngineCanvas` per screen. To restart a level from scratch, change its `key`: the whole scene remounts. ### Paused when off screen `EngineCanvas` listens to `onShellActive` ([Platform layer](https://docs.moonspell.app/engine/platform-layer.md)). While the game is not on screen it sets the frame loop to `'never'` (no `useFrame`, no physics, no particles) and redraws the loaded scene twice a second. When the game comes back, the clock skips the paused time. Outside the Moonspell app the game is always active, so this never triggers. ## `Environment` The scene's light and air in one component: a sky (three's procedural `SkyMesh` with clouds, a gradient dome with stars, or a flat colour), a sun with shadows that follow a target, hemisphere and ambient fill, fog, tone mapping and exposure on the renderer, and an environment map of the sky for PBR materials. Mount it once inside the canvas and do not add your own lights or `<fog>` for the same job. ```tsx <Environment preset="night" follow={() => body.current} fill={{ ground: '#2A2A3A' }} fog={{ near: 30, far: 90 }} /> ``` | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `preset` | `'day'`, `'sunset'`, `'night'`, `'overcast'`, `'neon'`, `'space'` | `'day'` | The starting config | | `follow` | a ref to an `Object3D`, or `() => ({ x, y, z })` | the origin | What the shadow area follows (the player, the car) | | `sky` | partial `SkyConfig` | from preset | `type` (`'procedural'`, `'gradient'`, `'color'`), `turbidity`, `rayleigh`, `mieCoefficient`, `mieDirectionalG`, `cloudCoverage`, `cloudDensity`, `sunDisc`, `top`, `bottom`, `stars` | | `sun` | partial `SunConfig` | from preset | `elevation` and `azimuth` (degrees), `intensity`, `color`, `shadows`, `shadowSize` (half-extent in metres, 28), `shadowMapSize` (1024 or 2048), `shadowBias`, `shadowNormalBias`, `shadowRadius`, `shadowStyle` (`'soft'` or `'crisp'`), `shadowIntensity` (0 to 1), `distance` | | `fill` | partial `FillConfig` | from preset | Hemisphere `sky` and `ground` colours, `intensity`, flat `ambient` and `ambientColor` | | `fog` | partial `FogConfig` | `linear`, near 60, far 260 | `type` (`'linear'`, `'exp'`, `'none'`), `color` (a colour or `'sky'`), `near`, `far`, `density` | | `toneMapping` | `'aces'`, `'agx'`, `'neutral'`, `'none'` | `'aces'` | Renderer tone mapping | | `exposure` | `number` | `0.5` (day) | Renderer exposure | | `environmentLight` | `boolean` | `true` | Light PBR materials with the sky | | `environmentIntensity` | `number` | `0.3` (day) | Strength of that light | Every override merges deeply over the preset. Helpers exported with it: `resolveEnvironment(preset, overrides)` returns the full `EnvironmentConfig`, `ENVIRONMENT_PRESETS` holds the preset overrides, `sunDirection(elevation, azimuth)` returns the unit vector toward the sun, and `presetForTimeOfDay('day' | 'sunset' | 'night')` maps the racing module's time of day. For hard shadows that read as a shape (painted or inked looks), use `sun={{ shadowStyle: 'crisp', shadowMapSize: 2048, shadowSize: 15 }}`. Keep the area small: sharp edges show the shadow map's resolution. ## `PostFX` Post-processing on three's WebGPU `RenderPipeline`: the scene renders at `resolutionScale`, then bloom, a colour grade and a vignette. Tone mapping and colour space are applied at the end from the renderer's settings. The HUD is React Native views and is never touched. Mount it once inside the canvas. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `preset` | `'clean'`, `'glow'`, `'cinematic'`, `'retro'` | `'glow'` | The starting config. `clean` turns bloom and vignette off | | `enabled` | `boolean` | `true` | Turn the whole chain off at runtime (a setting for older phones) | | `bloom` | `{ enabled, strength, radius, threshold }` | `true`, `0.45`, `0.5`, `0.85` | Bright pixels glow. The threshold is divided by the exposure each frame, so it means brightness on screen | | `vignette` | `{ enabled, strength, inner, outer }` | `true`, `0.35`, `0.45`, `0.95` | Darker corners | | `grade` | `{ saturation, contrast, lift, tint }` | `1.05`, `1.03`, `0`, `[1, 1, 1]` | Colour grade | | `ssao` | `boolean` | `false` | Ground-truth ambient occlusion (GTAO). Costly: high-end phones only | | `resolutionScale` | `number` | `0.75` (`retro`: `0.5`) | The scene renders at this fraction of the canvas and is upscaled. Clamped to 0.25 to 1 | Defaults above are the base before the preset applies (`glow` raises bloom to `0.7`, `0.6`, `0.8`). `resolvePostFX(preset, overrides)` and `POSTFX_PRESETS` are exported. When the config changes nothing (no bloom, no vignette, neutral grade, full scale), the component renders plainly and costs nothing. If the chain fails to build or a frame throws, it logs `[engine/postfx]` and falls back to plain rendering. ### `glow(material, color?, strength = 1.5)` Makes a standard or physical material emissive so it blooms: `color` sets the emissive colour (default: the material's own colour), `strength` the intensity (1 bright, 3 neon). Takes one material or an array. ```ts import { MeshStandardMaterial } from 'three'; import { glow } from '@/engine'; const lampMaterial = new MeshStandardMaterial({ color: '#FFF4E0' }); glow(lampMaterial, '#FFD27A', 2); ``` ## `EngineErrorBoundary` A React error boundary for GPU failures (no adapter, a shader that does not compile). Wrap the canvas in it so one failure does not take down the whole screen. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `children` | `React.ReactNode` | required | What it protects | | `label` | `string` | `'Engine'` | Shown as "`label` unavailable" and in the console warning | | `fallback` | `React.ReactNode` | a small error card | What to show instead. Pass `null` to show nothing | ## Game types (`core/`) `GameDefinition` is what `src/game/index.tsx` default-exports: | Field | Type | What it is | | --- | --- | --- | | `id` | `string` | URL-safe id | | `title` | `string` | The game's name | | `description` | `string` | One line about the game | | `badge` | `string`, optional | A short label (not shown by the app) | | `accent` | `string`, optional | An accent colour (not shown by the app) | | `Component` | `ComponentType<GameScreenProps>` | The full-screen game | `GameScreenProps` is `{ onExit: () => void }`. Call `onExit` from your exit or pause button. Module factories such as `defineRaceGame` return a `GameDefinition` for you. ## Renderer helpers `makeWebGPURenderer({ context, device?, antialias = true })` creates the `WebGPURenderer` for a `GPUCanvasContext`, and `disposeWebGPURenderer(renderer)` tears one down (stops three's animation loop, disposes, and clears shared listeners so the GPU memory can be reclaimed). `EngineCanvas` calls both. You only need them if you build your own canvas. ## Gotchas - Shadows always use VSM: the other shadow filters use a comparison sampler that loses the GPU device on react-native-webgpu's Metal backend. `Environment` sets this for you. Do not change `renderer.shadowMap.type`. - Keep per-frame values out of React state. Read and write them in `useFrame` on refs and mutable objects; a `setState` per frame re-renders the scene tree. - Phones are the budget. `shadowMapSize: 1024` is the phone default, `ssao` is off by default, `PostFX` renders at 0.75 scale. Use `<EngineCanvas debug>` in development to watch fps and draw calls. - Mesh pointer events work on web, iOS and Android: `onClick`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onPointerOver` / `onPointerOut` while pressed, and `onPointerMissed`. `EngineCanvas` installs R3F's event manager without its DOM listeners and feeds it from its own view's touch responder (`render/pointerFeed.ts`), the same code on every platform. A touch reaches a mesh only where nothing above the canvas takes it: HUD buttons, touch controls and gesture layers (for example the Kingdom town's `TownGestures`) keep their touches. The canvas only takes touches while the scene has a mesh with a pointer handler, so a game without any handles touches exactly as before. One finger at a time; there is no hover without a press. Verified in Chrome; not run on a phone. - A size change resizes the renderer instead of rebuilding it, so resizing is cheap, but do not put the canvas inside a view whose size animates every frame. - The engine's render hooks (`onBeforeRender`, `onAfterRender`, `setRenderOverride` in `render/frameHooks.ts`) are internal: they are not exported from `@/engine`. Game feel, the debug probe and `PostFX` use them. - `Water` (rivers, ponds, a sea) also lives in `render/`; it is documented with the other TypeGPU effects in [GPU effects](https://docs.moonspell.app/engine/gpu-effects.md). --- # Input Source: https://docs.moonspell.app/engine/input > The engine's multi-touch controls, TouchControls for racing, the floating Joystick and CharacterControls for character games, the input objects they write, and the keyboard they answer to in a desktop browser. The engine's controls (`src/engine/input/`) turn fingers into numbers your game loop reads every frame. Use them instead of `Pressable` buttons for anything held: React Native gives a touch to one view at a time, so two `Pressable`s cannot be held together (a player could not steer while holding gas). Each control tracks every finger in one gesture and writes into a plain object, without re-rendering React per frame. In a desktop browser the same controls also answer to the keyboard. | Control | For | Writes | | --- | --- | --- | | `TouchControls` | Racing: steer left and right, gas, brake | `InputState` | | `Joystick` | A floating thumb stick alone (top-down, survivors, a kingdom sortie) | `CharacterInput` and/or `onChange` | | `CharacterControls` | Stick, look pad and action buttons together (third person, platformers, shooters) | `CharacterInput` | All three need the app root to be inside `GestureHandlerRootView`. The template's `src/App.tsx` already does that. ## Smallest working example A character game's controls: a floating stick on the left half, a look pad and a jump button on the right. The loop reads them once per frame with `consumeInput`. ```tsx import React, { useMemo, useRef } from 'react'; import { View } from 'react-native'; import { useFrame } from '@react-three/fiber'; import { CharacterControls, EngineCanvas, consumeInput, createCharacterInput, createCommand, type CharacterInput, type GameScreenProps, } from '@/engine'; const Loop = ({ input }: { input: CharacterInput }) => { const cmd = useRef(createCommand()); useFrame(() => { const c = consumeInput(input, cmd.current); // c.moveX / c.moveY: the stick on the unit disc (+y = up on screen) // c.lookDX / c.lookDY: radians to turn this frame // c.jump: true for the one frame the button went down if (c.jump) console.log('jump'); }); return null; }; export const Screen = (_props: GameScreenProps) => { const input = useMemo(() => createCharacterInput(), []); return ( <View style={{ flex: 1 }}> <EngineCanvas> <Loop input={input} /> </EngineCanvas> <CharacterControls input={input} /> </View> ); }; ``` The controls are absolutely positioned over the whole screen, so render them after the canvas. Put HUD buttons in a later sibling with `pointerEvents="box-none"` so they sit on top. To move a character with this command, pass it to `stepCharacter` ([Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)). ## `TouchControls` (racing) Four zones in a bar at the bottom: steer left and right on one side, BRAKE and GAS on the other. It writes an `InputState`: ```ts interface InputState { steer: number; // -1 (left) .. 1 (right) throttle: number; // 0 .. 1 brake: number; // 0 .. 1 action: boolean; // a rising-edge flag the game loop sets back to false } ``` ```tsx import React, { useMemo } from 'react'; import { View } from 'react-native'; import { TouchControls, createInputState } from '@/engine'; export const Controls = ({ finished }: { finished: boolean }) => { const input = useMemo(() => createInputState(), []); return ( <View style={{ position: 'absolute', left: 0, right: 0, bottom: 0 }}> <TouchControls input={input} disabled={finished} layout="classic" /> </View> ); }; ``` | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `input` | `InputState` | required | The object it writes | | `disabled` | `boolean` | `false` | Ignore touches and keys | | `layout` | `'classic'` or `'mirrored'` | `'classic'` | `classic`: steering left, pedals right. `mirrored` swaps them (left-handed players) | | `onTouchCount` | `(count: number) => void` | none | Fingers down, for development telemetry | Zones have `testID`s `btn-left`, `btn-right`, `btn-gas`, `btn-brake` and accessibility labels `Steer left`, `Steer right`, `Accelerate`, `Brake`, so device automation can hold them. The racing module (`defineRaceGame`) uses this control. `createInputState()` makes a fresh `InputState`. `InputContext` and `useInputState()` share one through React context: `useInputState()` returns the provided object, or a component-local one when there is no provider. ## `Joystick` A floating virtual stick: the base appears where the thumb lands and stays there, the knob follows the thumb inside the rim. The reading uses a scaled radial dead zone (output starts at 0 at the dead zone's edge and reaches 1 at the rim, so slow movement is possible) over a travel measured in millimetres, so it feels the same on every phone. The knob is drawn with Reanimated shared values. One gesture tracks the stick's finger and ignores the rest, so HUD buttons above it keep working. ```tsx import React, { useMemo } from 'react'; import { View } from 'react-native'; import { Joystick, createCharacterInput } from '@/engine'; export const Sortie = ({ paused }: { paused: boolean }) => { const input = useMemo(() => createCharacterInput(), []); // read input.moveX / input.moveY in your useFrame return ( <View style={{ flex: 1 }}> <Joystick input={input} zone="full" disabled={paused} /> </View> ); }; ``` | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `input` | `CharacterInput` | none | Gets `moveX` and `moveY` on every move | | `onChange` | `(reading: JoystickReading) => void` | none | Called on every change, and once with an idle reading on release | | `zone` | `'full'`, `'left'`, `'right'` or `{ left?, top?, right?, bottom? }` | `'full'` | Where a touch starts the stick. `full`: anywhere below the top 14 percent. `left` and `right`: that half, below the top 30 percent. A rect is fractions of the screen (`{ top: 0.3 }` is everything below 30 percent) | | `radiusMm` | `number` | `11` | Travel to full deflection in millimetres, clamped to 7 to 16 | | `deadZone` | `number` | `0.12` | Fraction of the travel ignored around the centre (0 to 0.5) | | `dynamic` | `boolean` | `false` | Drag the base along when the thumb passes the rim (instant reversals) | | `stayVisible` | `boolean` | `false` | Keep the base on screen between touches | | `disabled` | `boolean` | `false` | Ignore touches and keys | | `color` | `string` | `'#FFFFFF'` | Ring and knob tint | | `onTouchCount` | `(count: number) => void` | none | Development telemetry | | `testID` | `string` | `'joystick'` | On the root view (accessibility label `Move joystick`) | `JoystickReading` is `{ x, y, magnitude, angle, active }`: `x` and `y` on the unit disc (+y up on screen), `magnitude` 0 to 1 after the dead zone, `angle` in radians (0 is right, counter-clockwise, `NaN` when idle). The maths is exported for your own sticks and for bots that pretend to hold one: `readStick(dx, dy, radius, deadZone, out?)`, `followTouch(center, tx, ty, radius, dynamic, out?)`, `inZone(zone, x, y, w, h)`, `mmToPoints(mm)`, `idleReading()` and `DEFAULT_JOYSTICK` (`{ radius: 71, deadZone: 0.12, dynamic: false }`, radius in points: 11 mm). ## `CharacterControls` Stick, look pad and buttons in one multi-touch gesture, so moving, looking and jumping happen at once and fingers may slide between buttons (except a `look` button, which keeps its finger). The stick floats where the thumb lands on its half; dragging on the other half turns the camera; buttons sit in the bottom corner opposite the stick. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `input` | `CharacterInput` | required | The object it writes | | `disabled` | `boolean` | `false` | Ignore touches and keys | | `stick` | `'left'` or `'right'` | `'left'` | Which half the stick lives in | | `look` | `boolean` | `true` | Dragging on the other half turns the camera. `false` hides the look zone and touches there do nothing; buttons with `look: true` still aim | | `buttons` | `ControlButton[]` | one Jump button (`id: 'jump'`, size 92) | Action buttons | | `stickRadius` | `number` | `56` | Stick travel in points, clamped to 40 to 90 | | `deadZone` | `number` | `0.12` | Fraction of the radius ignored | | `secondThumb` | `boolean` | `false` | No fixed buttons: the first touch anywhere is the stick, a second touch while it is held presses the first button where that thumb lands, and dragging it 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 screen fractions (the middle, where the character stands) | | `onTouchCount` | `(count: number) => void` | none | Development telemetry | A `ControlButton` is: | Field | Type | What it is | | --- | --- | --- | | `id` | `string` | `jump`, `sprint`, `a` and `b` are understood by `consumeInput`; other ids are read from `input.held` and `input.pressed` | | `label` | `string` | The text on the button | | `accessibilityLabel` | `string`, optional | Defaults to `label` | | `size` | `number`, optional | Diameter in points. Default 76 | | `color` | `string`, optional | Border and fill tint | | `look` | `boolean`, optional | The finger on this button also turns the camera while it drags (a FIRE button: hold to shoot, drag to aim). The button stays held until that finger lifts, wherever it slides | ```tsx <CharacterControls input={input} buttons={[ { id: 'jump', label: 'JUMP', accessibilityLabel: 'Jump', size: 92 }, { id: 'a', label: 'GRAB', accessibilityLabel: 'Grab' }, ]} /> ``` Automation handles: the root is `character-controls`, the stick zone `stick-zone` (label `Move stick`), the look zone `look-zone` (label `Look`), each button `btn-<id>`; with `secondThumb`, the floating button is `btn-thumb`. ### `CharacterInput` and `consumeInput` `CharacterInput` is what the stick and the thumbs did since the loop last looked: | Field | What it is | | --- | --- | | `moveX`, `moveY` | The stick on the unit disc, +y up on screen | | `lookDX`, `lookDY` | Look-pad drag since the last consume, in pixels | | `held` | Buttons under a finger right now, by id | | `pressed` | Buttons that went down since the last consume, by id | | Function | What it does | | --- | --- | | `createCharacterInput()` | A fresh, zeroed input | | `resetCharacterInput(input)` | Zero everything (a respawn) | | `consumeInput(input, out, lookSensitivity?)` | Fill a `CharacterCommand` for this frame and clear what was consumed (look deltas, rising edges). Returns `out` | | `DEFAULT_LOOK_SENSITIVITY` | `0.0048` radians per pixel of drag | `consumeInput` maps `pressed.jump` to `jump`, `pressed.a` to `actionA`, `pressed.b` to `actionB` and `held.sprint` to `sprint`. Dragging right turns the view right; dragging down looks down. `createCommand()` makes the `CharacterCommand` to fill. ## Keyboard in a desktop browser A mouse is one finger: it cannot hold gas and steer, or move and jump. So in a browser each control also listens to the keyboard (`input/keyboard.ts`) and feeds the same input the thumbs do. A game built on these controls plays with keys without knowing it. On the phones no listener is added. | Control | Keys | | --- | --- | | `TouchControls` | A or Left steer left, D or Right steer right, W or Up gas, S, Down or Space brake | | `Joystick` | WASD or the arrows push the stick to full tilt in eight directions | | `CharacterControls` | WASD or the arrows move. By position in `buttons`: the first is Space, the second J, E or F, the third K or Q, the fourth L. A button with id `sprint` is Shift wherever it is listed. Dragging the mouse on the look half turns the camera | Keys are `KeyboardEvent.code` values (the physical key), so WASD stays under the left hand on any layout. A thumb on the stick wins over the keys. Keys are ignored while an input field has focus or Cmd, Ctrl or Alt is held, and the browser's scrolling on arrow keys and Space is cancelled while a control listens. Leaving the tab releases every key. ## Gotchas - Touches are mapped in the control's own coordinates (measured with `measureLayout`), never window coordinates. The Moonspell app shows games scaled in its feed and zooms them; window positions measured once go stale. Follow the same rule in your own controls. - Keep zones at least 72 points and 12 points apart. Thumbs are imprecise. - Show pressed state from React state that changes only when the set of pressed buttons changes, as these controls do. Never per frame. - A finger that lands on a plain button holds whichever button is under it as it slides, and releases when it leaves them all. A finger on a `look: true` button keeps that button until it lifts, and its drag adds to `lookDX` and `lookDY` like the look pad. - `useKeys`, `keyAxis` and `keysHoldButton` (in `input/keyboard.ts` and `input/keyMap.ts`) are not exported from `@/engine`. Import them by path if you build your own control. - For menu and HUD buttons that are tapped, not held, use `PressScale` from `@/engine/ui` ([HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)). For taps and drags in a flat game, `GameCanvas2D` has its own pointer events ([The 2D layer](https://docs.moonspell.app/engine/2d.md)). --- # HUD and UI Source: https://docs.moonspell.app/engine/hud-and-ui > The HUD pieces in @/engine (TopBar, RoundButton, FinishCard, Banner, DebugRow, the Skia Gauge) and the game UI kit in @/engine/ui (SF Symbol icons, bundled fonts, rolling counters, press and pop motion, sprite-sheet images). A game's HUD and menus are React Native views drawn over the canvas. The engine gives you two sets of pieces: - **HUD chrome** in `src/engine/hud/`, exported from `@/engine`: a top bar with an exit button, round buttons, a finish card, a banner, a development row, and a Skia speedometer. - **The game UI kit** in `src/engine/ui/`, imported from `@/engine/ui`: icons, bundled game fonts, rolling number counters, press and pop motion, floating "+10" text and sprite-sheet images. Use the chrome to get a playable screen fast; use the kit to give the game its own look. ## Smallest working example A HUD with an exit button, a lives counter and a score that rolls when it changes, in one bundled font family: ```tsx import React from 'react'; import { StyleSheet, View } from 'react-native'; import { TopBar, type GameScreenProps } from '@/engine'; import { Bump, Counter, GAME_ICONS, Icon, useGameFonts } from '@/engine/ui'; import { nunitoBlack } from '@/engine/ui/fonts/nunitoBlack'; import { nunitoBold } from '@/engine/ui/fonts/nunitoBold'; const fonts = { heavy: nunitoBlack.family, light: nunitoBold.family }; export const Hud = ({ onExit, score, lives }: GameScreenProps & { score: number; lives: number }) => { const ready = useGameFonts(nunitoBlack, nunitoBold); if (!ready) return null; return ( <View style={StyleSheet.absoluteFill} pointerEvents="box-none"> <TopBar title="Lantern Run" status="Find the lanterns" rightLabel="LIVES" rightIcon="heart.fill" rightIconColor="#FF5C7A" rightValue={String(lives)} onExit={onExit} /> <View style={{ position: 'absolute', top: 110, alignSelf: 'center', flexDirection: 'row', alignItems: 'center', gap: 6 }} pointerEvents="none"> <Icon name={GAME_ICONS.star} size={22} color="#FFD23F" /> <Bump value={score}> <Counter value={score} fontFamily={fonts.heavy} fontSize={28} color="#FFFFFF" /> </Bump> </View> </View> ); }; ``` ## HUD chrome (`@/engine`) These pieces use the template's `colors` from `src/theme.ts`. Keep per-frame numbers out of them: they are React state, updated on change and at most a few times a second. ### `TopBar` Exit button, title and status in the middle, an optional value on the right. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `title` | `string` | required | Small caps line | | `status` | `string` | required | The big centre line (`testID` `hud-status`) | | `rightLabel` | `string` | none | Small label on the right (`TIME`, `LIVES`) | | `rightValue` | `string` | none | The value under it (`testID` `hud-right`) | | `rightIcon` | `IconName` | none | A symbol before the value | | `rightIconColor` | `string` | `colors.text` | Its colour | | `onExit` | `() => void` | required | The exit button (`testID` `btn-exit`) | | `exitLabel` | `string` | `'Exit game'` | Its accessibility label | | `children` | `React.ReactNode` | none | Extra buttons on the right (camera, mute) | ### `RoundButton` A small round button for the top bar. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `testID` | `string` | required | For automation | | `accessibilityLabel` | `string` | required | What it does | | `onPress` | `() => void` | required | | | `icon` | `IconName` | none | A symbol (preferred over text children) | | `children` | `React.ReactNode` | none | Text, when there is no icon | | `active` | `boolean` | `false` | Fills it with `accent` | | `accent` | `string` | `colors.accent2` | The active colour | ### `FinishCard` A centred card for the end of a round: a big word, a few lines, Play again and Back to games. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `title` | `string` | required | The big word (`testID` `hud-finished`) | | `lines` | `string[]` | required | Lines under it | | `restartLabel` | `string` | required | The primary button (`testID` `btn-restart`) | | `onRestart` | `() => void` | required | | | `onExit` | `() => void` | required | The secondary button, labelled `Back to games` (`testID` `btn-finish-exit`) | | `accent` | `string` | `colors.accent2` | The primary button's colour | ### `Banner` A big centred word (a countdown, GO, a hint). Props: `text` (an empty string renders nothing), `color` (default `colors.text`), `testID` (default `hud-banner`). ### `DebugRow` The development row: an AUTO toggle (`testID` `btn-autopilot`) and a telemetry line (`testID` `hud-debug`) that device automation reads. Props: `debug` (the line; `undefined` hides the row, which is how you keep it out of production), `autopilot`, `onToggleAutopilot`, `accent`. ### `Gauge` A Skia speedometer: a 240 degree arc and a number. Reanimated shared values drive the arc and the label on the UI thread, so the game loop never re-renders React. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `ratio` | `SharedValue<number>` | required | Fill, 0 to 1 | | `value` | `SharedValue<number>` | required | The number in the middle | | `unit` | `string` | `'km/h'` | The line under it | | `size` | `number` | `140` | Width and height in points | | `color` | `string` | `colors.accent2` | The arc colour | ## The game UI kit (`@/engine/ui`) ### `Icon` and `GAME_ICONS` Every icon is an SF Symbol drawn with `expo-symbols`. On iOS it is the native symbol; on Android and the web the same name draws its Material Symbol twin from `src/engine/ui/androidSymbols.ts`. `name` is type-checked against the SF Symbols list, so a typo fails `tsc`. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `name` | `IconName` | required | An SF Symbol name | | `size` | `number` | `24` | Points | | `color` | `ColorValue` | `'#FFFFFF'` | Tint | | `weight` | `SymbolWeight` | `'bold'` | Match the text next to it | | `type` | `'monochrome'`, `'hierarchical'`, `'palette'`, `'multicolor'` | `'monochrome'` | Rendering mode; `palette` takes `colors` | | `colors` | `ColorValue[]` | none | For `palette` | | `animationSpec` | `AnimationSpec` | none | A native symbol effect; it plays when the object changes, so pass a new one per event | | `style` | `StyleProp<ViewStyle>` | none | | | `accessibilityLabel` | `string` | none | Only for icons that stand alone; icons next to a label are hidden from accessibility | `GAME_ICONS` maps common game concepts to symbols so every game names them the same way: `close`, `pause`, `play`, `restart`, `back`, `next`, `settings`, `soundOn`, `soundOff`, `music`, `haptics`, `share`, `help`, `hint`, `camera`, `lock`, `unlock`, `check`, `score`, `star`, `starEmpty`, `trophy`, `crown`, `medal`, `coin`, `gem`, `gift`, `level`, `finish`, `record`, `leaderboard`, `combo`, `heart`, `heartEmpty`, `shield`, `energy`, `timer`, `stopwatch`, `speed`, `boost`, `slow`, `target`, `key`, `map`, `pin`, `players`, `player`, `online`, `bomb`, `magic`, `moon`, `sun`, `water`, `ice`, `leaf`, `dice`, `cards`, `gamepad`. ```tsx <Icon name={GAME_ICONS.coin} size={20} color="#FFD23F" /> ``` ### Fonts: `useGameFonts` and the bundled faces The engine bundles 18 open-licence (OFL) faces, one module per face in `@/engine/ui/fonts/`, so a game's bundle carries only the faces it imports. Each is a `GameFont`: ```ts interface GameFont { family: string; // the fontFamily it registers as, e.g. 'Nunito_900Black' source: number; // the .ttf asset, a require() id } ``` | Family | Faces (module names) | | --- | --- | | Chakra Petch | `chakraPetchBold`, `chakraPetchSemiBold` | | Orbitron | `orbitronBlack`, `orbitronBold` | | Fredoka | `fredokaBold`, `fredokaSemiBold` | | Nunito | `nunitoBlack`, `nunitoBold` | | Cinzel | `cinzelBlack`, `cinzelBold` | | Single faces | `russoOne`, `bungee`, `lilitaOne`, `bangers`, `blackOpsOne`, `vt323`, `pressStart2P`, `creepster` | `useGameFonts(...faces)` registers the faces with `expo-font` and returns `true` once they are ready, or once loading failed, so a game never waits forever. Render the title screen after that so text does not jump from the system font. It warns in development when the faces come from more than one family: the template's rule is one family per game, with hierarchy from its weights and sizes. `familyOf(font)` returns the family part of a face (`ChakraPetch_700Bold` becomes `ChakraPetch`). Use `font.family` as `fontFamily` and never set `fontWeight` on it: the weight is part of the face. For Skia text, load the same face with Skia's `useFont(font.source, size)`. ### `Counter` Every number in the UI that changes: the changed digits roll to the new value on the UI thread (built on react-native-laminar). | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `value` | `number` | required | The number | | `fontFamily` | `string` | required | The game's face | | `fontSize` | `number` | required | | | `color` | `string` | required | | | `kind` | `'roll'`, `'ticker'`, `'slots'` | `'roll'` | `roll` for numbers that grow, `ticker` for many changes a second, `slots` for a rare big reveal | | `prefix`, `suffix` | `string` | `''` | Text around the number (`suffix=" m"`) | | `format` | `(n: number) => string` | thousands separators | How the number becomes digits | | `from` | `number` | none | Shown first, then animated to `value` after mount (a results score counting up from 0) | | `align` | `'left'`, `'center'`, `'right'` | `'left'` | | | `style`, `testID` | | | On the wrapping view | Use Laminar only through `Counter`: importing `react-native-laminar` directly makes TypeScript check its raw source, which fails the strict typecheck. ### Motion pieces | Export | What it does | | --- | --- | | `PressScale` | The base for every menu and HUD button: a `Pressable` that sinks while held (`scale`, default `SCALE.press`) and fires a haptic on press-in (`haptic`: `'tap'` default, `'select'`, `'heavy'` or `false`). Takes every `Pressable` prop; `hitSlop` defaults to 8 | | `Bump` | Wrap a counter: it swells to `amount` (default `SCALE.pop`) and settles each time `value` changes, never on mount | | `useShake()` | Returns `{ style, shake(strength = 5) }`. Put `style` on an `Animated.View`; `strength` is points of travel (3 a bump, 6 a crash, 10 a boss hit) | | `useFloaters()` and `Floaters` | `const { floaters, spawn } = useFloaters()`; `spawn(text, x, y, color?)` adds a "+10" that rises and fades over 750 ms; render `<Floaters items={floaters} textStyle={...} />` above the game. Keeps the last 12 | Reduced motion (the system setting) is respected: `Bump` and `useShake` do nothing, `Floaters` fade in place. ### The motion vocabulary (`motion.ts`) Shared values so all games move alike, exported from `@/engine/ui`: | Export | Value | | --- | --- | | `DURATION` | `blink` 50, `press` 120, `small` 180, `panel` 260, `flight` 420 (ms) | | `SCALE` | `press` 0.96, `pressBig` 0.92, `pop` 1.08, `popBig` 1.15, `loop` 1.02, `alert` 1.06 | | `STAGGER` | 70 ms between items arriving one after another | | `SPRING` | `settle`, `snap`, `pop` (Reanimated spring configs, damping ratio 0.8 to 1) | | `EASE_OUT`, `EASE_IN_OUT` | Easing curves for `withTiming` | | `CSS_EASE_OUT`, `CSS_EASE_IN_OUT` | The same curves for Reanimated CSS transitions and animations | | `rise(delay = 0, duration = 260)` | An entrance: fade in, rise 10 points, grow from 0.96. Use as `entering={rise(i * STAGGER)}` | | `land(delay = 0)` | A banner's entrance: fade in while landing from 1.15 | | `leave(duration = 180)` | The exit: a fade | | `popTo(amount = SCALE.pop)` | A pop for a shared value at rest at 1: `scale.set(popTo())` | | `transition(property = 'transform', duration = 120)` | A CSS transition object to spread into an `Animated` style | | `breathe(duration = 1800)` | An idle scale loop to spread into an `Animated` style | | `pulse(duration = 500)` | An urgent scale loop (low time, low health) | `rise`, `land` and `leave` return a new animation each call: call them in JSX, do not share one instance. ### `SheetImage` and `isPlaceholderImage` One cell of a sprite sheet as a React Native view: resource icons, reward items, portraits. It crops the cell the CSS-sprite way, so nothing is decoded per cell. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `source` | `number` | required | The `require()`d sheet | | `cols`, `rows` | `number` | required | The grid | | `frame` | `number` | required | Cell index, reading order | | `size` | `number` | required | Rendered size in points (cells are square) | | `fallback` | `React.ReactNode` | `null` | Shown while the sheet is a 1 by 1 placeholder image | | `style`, `accessibilityLabel` | | | On the wrapping view | `isPlaceholderImage(source)` is true when a bundled image is 1 by 1 pixels: the hosted Moonspell pipeline writes such placeholders before the real art is generated. For sprites in the 3D scene, see `Billboard` in [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md). ## Gotchas - No emoji and no text symbols as icons (a cross, heart or star character): they render differently per device and cannot be tinted. Use `Icon`. SF Symbols has no swords or skulls; draw those, or pick the nearest symbol. - An SF Symbol missing from `androidSymbols.ts` draws a plain circle on Android and the web. When you use a symbol that is not in that map, add its Material Symbol name (any name from fonts.google.com/icons). - Never pass a Reanimated `Keyframe` to `entering` in a game. In a browser, Reanimated's web build pins the element where it stood when the keyframe ends, and the next items slide under it. `rise` and `land` use built-in entrances on the web for this reason. - `HudChrome` and `Gauge` use the template's `src/theme.ts` colours and the system font. For a game with its own look, build its screens from the UI kit with its palette and font. - In a browser `matchFont()` in Skia answers with the engine's Nunito, so the `Gauge` label is Nunito there ([Platform layer](https://docs.moonspell.app/engine/platform-layer.md)). - Haptics come from `expo-haptics`. Failures are caught, so a platform without haptics stays silent. --- # Audio Source: https://docs.moonspell.app/engine/audio > The audio mixer (useAudioMixer, AudioMixer, AudioListener) with buses, positional sounds, random pitch, voice limits, ducking and music crossfades, plus the mixing maths and RaceAudio. The engine's audio (`src/engine/audio/`) is a mixer on `react-native-audio-api` (the Web Audio API on React Native). Use one mixer per game screen for every sound: effects placed in the world (louder when near, panned to their side), random pitch so repeats do not sound copied, a few voices per sound, music that ducks under loud effects and crossfades between tracks, and volume settings saved on the device. For a flat game with a handful of short sounds, the 2D layer's `useOneShots` (on `expo-audio`) is lighter ([The 2D layer](https://docs.moonspell.app/engine/2d.md)). ## Smallest working example Music on its bus, a positional pop, and a thud that ducks the music, as the Lantern Tag example does: ```tsx import React, { useEffect } from 'react'; import { View } from 'react-native'; import { AudioListener, EngineCanvas, useAudioMixer, type GameScreenProps } from '@/engine'; const SOUNDS = { music: require('../../assets/audio/music-synthwave.m4a'), lantern: require('../../assets/sfx/pop.wav'), tagged: require('../../assets/audio/bump.m4a'), }; export const Screen = (_props: GameScreenProps) => { const mixer = useAudioMixer(SOUNDS); useEffect(() => { mixer?.playMusic('music', { volume: 0.55, fade: 2 }); }, [mixer]); const onLantern = (x: number, z: number) => mixer?.play('lantern', { at: { x, y: 1, z } }); const onTagged = () => mixer?.play('tagged', { duck: true, pitchJitter: 0.08 }); return ( <View style={{ flex: 1 }}> <EngineCanvas> <AudioListener mixer={mixer} /> {/* the scene; call onLantern / onTagged from gameplay */} </EngineCanvas> </View> ); }; ``` `useAudioMixer` returns `null` while the sounds decode (and if audio cannot start), so call it with `mixer?.`. It closes the mixer when the screen unmounts, so nothing keeps playing behind the games list. `<AudioListener>` inside the canvas makes the camera the listener every frame. Define the sounds object outside the component (or memoise it). The mixer is recreated when the set of names and assets changes. ## `useAudioMixer(sounds)` | Argument | Type | What it is | | --- | --- | --- | | `sounds` | `Record<string, number>` | Names to `require()`d audio files (`.wav`, `.m4a`) | Returns `AudioMixer | null`. In development it adds an `audio` line to the debug overlay (sounds loaded, voices playing, music track, ducked, muted). ## `AudioMixer` | Method | What it does | | --- | --- | | `play(name, options?)` | Play a sound. Returns a `Voice`, or `null` when the sound is not loaded or the mixer is closed | | `playMusic(name, { fade?, volume?, loop? })` | Start a track on the music bus, crossfading from the current one. `fade` 1.2 s, `volume` 1, `loop` true by default. Playing the same track again does nothing | | `stopMusic(fade = 0.8)` | Fade the music out and stop it | | `setListener({ x, y, z, yaw })` | Where the ears are; moves every positional sound. `<AudioListener>` calls it for you | | `getSettings()` | The current `AudioSettings` | | `setSettings(change)` | Change and save settings: `{ muted: true }`, `{ music: 0.4 }` | | `load(sounds)` | Decode more sounds later (a level's music). Files that fail are skipped with a warning | | `has(name)` | Whether a sound is decoded | | `duration(name)` | Seconds, or 0 when not loaded | | `dispose()` | Stop everything and close the audio context. `useAudioMixer` calls it on unmount | Read-only state for telemetry: `voiceCount`, `loadedCount`, `musicTrack`, `ducked`, `played`. `AudioMixer.create(sounds)` is the async factory behind the hook, for code outside React. ### `PlayOptions` | Option | Type | Default | What it does | | --- | --- | --- | --- | | `bus` | `'music'`, `'sfx'`, `'ui'` | `'sfx'` | Which volume it obeys | | `volume` | `number` | `1` | 0 to 1; more boosts | | `at` | `{ x, y?, z }` | none | Place the sound in the world: louder near the listener, panned to its side | | `falloff` | `{ near?, far?, rolloff? }` | `near` 2, `far` 40, `rolloff` 1 | Full volume inside `near` metres, silent beyond `far` | | `pitch` | `number` | `1` | Playback rate (2 is an octave up) | | `pitchJitter` | `number` | `0.04` for effects, `0` for music | Random pitch, a fraction (0.06 is plus or minus 6 percent) | | `volumeJitter` | `number` | `0.1` for effects, `0` for music | Random volume, a fraction | | `loop` | `boolean` | `false` | Loop it (an engine, a fire, ambience) | | `maxVoices` | `number` | `4` | Voices of this sound at once; a new one past the limit stops the oldest | | `duck` | `boolean` | `false` | Lower the music while it plays (explosions, a big hit). Ignored on loops | ### `Voice` | Member | What it does | | --- | --- | | `stop(fade?)` | Fade out over `fade` seconds (default 0.05) and stop | | `setPosition(at)` | Move a looping positional sound | | `ended` | `true` once it stopped | ### `AudioSettings` `{ muted, master, music, sfx, ui }`, defaults `false`, `1`, `0.7`, `1`, `0.8`. They are saved per game on the device. The player's game settings from the save module (`sound`, `music`, `volume`, see [Saves](https://docs.moonspell.app/engine/saves.md)) gate the buses on top: sound off silences `sfx` and `ui`, music off silences `music`, and `volume` scales the master. ## The mixing maths The pure functions behind the mixer are exported for your own sound code and tests: | Export | What it does | | --- | --- | | `attenuation(distance, falloff?)` | Volume 0 to 1 for a distance: 1 inside `near`, inverse distance after, eased to 0 at `far` | | `panFor(listener, at)` | Stereo pan -1 (left) to 1 (right) of a point heard by the listener | | `jitter(base, amount, rand?)` | `base` moved by up to plus or minus `amount` (a fraction) | | `semitones(n)` | Semitones to a playback rate (12 is 2) | | `VoicePool` | At most `max` voices; `admit(voice, now)` returns the oldest voice to stop when full | | `Ducker` | Music ducking state: `duck(start, duration)`, `ducked(now)`, `gainAt(now)` | Types: `Listener` is `{ x, y, z, yaw }` with yaw 0 facing +z; `Falloff` is `{ near?, far?, rolloff? }`. ## `RaceAudio` The racing module's sound: two engine loops crossfaded and pitched by speed, a tyre squeal when cornering or braking hard, one-shots for accelerating, braking, barrier bumps and rivals passing, and a music bed. It plays the template's files in `assets/audio/` through `expo-audio`. You only need it if you build your own racing game outside `defineRaceGame`: ```ts import { RaceAudio } from '@/engine'; const audio = new RaceAudio(); export async function startRace() { await audio.start(); } /** Call once per simulation frame. */ export function raceFrame(speedRatio: number, throttle: number, brake: number, steer: number, bump: number, dt: number) { audio.update({ speedRatio, throttle, brake, steer: Math.abs(steer), bump, racing: true, dt }); } /** A rival went past the player. */ export function rivalPassed(relativeSpeed: number) { audio.passBy(relativeSpeed); } export function endRace() { audio.dispose(); } ``` Other members: `setMuted(muted)`, `muted`, `musicVolume` (0.45) and `stats` (one-shot counts for telemetry). ## Gotchas - Off screen in the Moonspell app's feed, the mixer suspends its audio context and `RaceAudio` pauses its loops, so nothing plays from a card that is not shown. Outside the app the game is always active. - Keep footsteps and other frequent sounds short, soft and limited: `maxVoices: 2` and some `pitchJitter`. - Trim and level generated audio before you ship it. The template's sound-design skill has an `ffmpeg` recipe (`silenceremove` and `loudnorm`). Players download every asset with the game, so keep the total small. - Every sound goes through `require()`. Never build a path or URL to an audio file by hand: the web build names assets by address. - Browsers hold audio back until the page gets a user gesture (their autoplay policy): a game's mixer starts suspended and is silent until then. In a browser the mixer resumes on the first tap, click or key, and `RaceAudio` starts its loops then (not while the app keeps the game off screen). Phones start sound without a gesture, as before. --- # Physics Source: https://docs.moonspell.app/engine/physics > Rigid bodies on cannon-es behind a typed API (Physics, RigidBody, StaticBody, LevelBodies, TriggerVolume, Joint, PhysicsWorld), the player's capsule, collisions and rays, the drivable Vehicle, and GPU Debris for thousands of small pieces. The physics module (`src/engine/physics/`, exported from `@/engine`) makes the world react: crates that fall and stack, balls that bounce, doors on hinges, pickups through trigger volumes, impacts that thud. It has two tiers behind one typed vocabulary: - **Rigid bodies** for the few objects that matter, on cannon-es (pure JavaScript, no WebAssembly), stepped at a fixed 60 Hz from the frame loop. - **GPU debris** for the many small things (crash pieces, coins, glass, hail, confetti): thousands of pieces simulated in a TypeGPU compute shader, drawn in one call. They bounce off the world, not off each other. Units are metres, kilograms and seconds; +Y is up. Gameplay rules (scores, lives) stay in your code. Physics is what the world does when it is hit. The player's character keeps the character controller for movement ([Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)); its capsule shoves dynamic bodies and is never pushed. ## Smallest working example A floor and a crate that falls onto it, with a thud on impact: ```tsx import React from 'react'; import { EngineCanvas, Environment, Physics, RigidBody, StaticBody, impactStrength, useCollisions } from '@/engine'; const Thuds = () => { useCollisions((event) => { const strength = impactStrength(event, 1, 10); // 0..1 if (strength > 0.1) console.log('thud', strength.toFixed(2)); }); return null; }; export const CrateScene = () => ( <EngineCanvas camera={{ position: [0, 4, 8] }}> <Environment preset="day" /> <Physics> <Thuds /> <StaticBody shape={{ kind: 'box', w: 20, h: 1, d: 20 }} position={[0, -0.5, 0]}> <mesh receiveShadow> <boxGeometry args={[20, 1, 20]} /> <meshStandardMaterial color="#5A6A5A" /> </mesh> </StaticBody> <RigidBody shape={{ kind: 'box', w: 0.8, h: 0.8, d: 0.8 }} mass={4} material="wood" position={[0, 4, 0]} rotation={[0.3, 0.4, 0]}> <mesh castShadow> <boxGeometry args={[0.8, 0.8, 0.8]} /> <meshStandardMaterial color="#A86F3A" /> </mesh> </RigidBody> </Physics> </EngineCanvas> ); ``` Put the meshes at the body's origin inside `RigidBody`: the body moves the group. In a level made with the character foundation, add `<LevelBodies level={level} />` inside `<Physics>` and the level's boxes become solid. ## Components ### `Physics` Owns one `PhysicsWorld` and steps it every frame before anything reads positions (priority `-10`), then dispatches the frame's collision and trigger events in order. | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `gravity` | `Vec3` | `{ x: 0, y: -9.81, z: 0 }` | m/s² | | `fixedStep` | `number` | `1/60` | Seconds per physics step | | `maxSubsteps` | `number` | `4` | Steps one frame may run to catch up before time is dropped | | `paused` | `boolean` | `false` | Freeze the simulation (a pause menu) | | `timeScale` | `number` | `1` | Simulation speed | | `world` | `PhysicsWorld` | its own | A world you made yourself (tests, gameplay code that shares it) | Frame deltas are capped at 0.1 s before stepping. ### Bodies | Component | What it is | | --- | --- | | `RigidBody` | A dynamic body: falls, rolls, gets knocked about | | `StaticBody` | Never moves: walls, floors. May use any shape, trimeshes included | | `TriggerVolume` | An invisible static zone that reports bodies entering and leaving; collides with nothing | | `LevelBodies` | Makes a character-foundation `Level` solid: a static box per level box and a ground slab under the floor. Moving platforms are skipped | Props shared by `RigidBody` and `StaticBody`: | Prop | Type | What it does | | --- | --- | --- | | `shape` | a `Shape` (below) | Required. `RigidBody` takes any shape except `trimesh` | | `position` | `[x, y, z]` or `Vec3` | Where it starts | | `rotation` | `[x, y, z]` | Euler radians | | `material` | `'default'`, `'wood'`, `'metal'`, `'rubber'`, `'ice'` or `{ friction?, restitution? }` | Surface. Default friction 0.4, restitution 0.1 | | `shapeOffset` | `Vec3` | Where the shape sits relative to the body origin | | `name` | `string` | For debugging and events | | `isTrigger` | `boolean` | Report overlaps instead of colliding | | `onBody` | `(id: BodyId) => void` | The body's id, for `world.positionOf(id)` and joints | | `onCollide` | `(event, other) => void` | This body started touching another | | `children` | `React.ReactNode` | What to draw | Extra `RigidBody` props: `mass` (kg, default 1), `velocity`, `angularVelocity`, `damping` (default 0.01 per second), `allowSleep` (default true), `ignoredByWheels` (vehicle wheels roll through it instead of climbing it: cones, bottles). `TriggerVolume` props: `shape` (not a trimesh), `position`, `rotation`, `onEnter(other)`, `onExit(other)`, `onBody`, `children`. ```tsx <TriggerVolume shape={{ kind: 'sphere', radius: 0.7 }} position={[3, 0.7, 2]} onEnter={(other) => { if (other === playerBody.current) collect(); }}> <mesh> <sphereGeometry args={[0.3]} /> <meshStandardMaterial color="#FFD23F" /> </mesh> </TriggerVolume> ``` ### Shapes | `kind` | Fields | Notes | | --- | --- | --- | | `'box'` | `w`, `h`, `d` | Full size along x, y, z | | `'sphere'` | `radius` | | | `'capsule'` | `radius`, `height` | Upright; `height` includes the caps | | `'cylinder'` | `radius`, `height` | Upright | | `'convex'` | `points: Vec3[]` | The convex hull of the points, relative to the body origin | | `'trimesh'` | `vertices: number[]`, `indices: number[]` | Exact triangles; static bodies only | Helpers: `convexFromPoints(points, maxVertices = 64)`, `meshPoints(object3d)` (the points of a mesh, for a hull) and `shapeHalfExtents(shape)`. `MATERIALS` holds the named materials. When two bodies touch, friction is multiplied and bounce takes the larger value. ### `Joint` A joint between two bodies, by id (from `onBody`), removed on unmount. It is only added once both bodies exist. | `kind` | Fields | What it is | | --- | --- | --- | | `'hinge'` | `pivot`, `axis`, `pivotB?`, `limits?: { min, max }`, `motor?: { speed, maxTorque? }` | A door, a wheel, a pendulum. Limits are radians from the rest pose | | `'point'` | `pivot`, `pivotB` | Ball and socket | | `'distance'` | `distance?` | A rope or rod (default: the current distance) | | `'lock'` | | A weld | All joints take `a`, `b`, `collide?` (let the two bodies hit each other, default false) and `onJoint?(id)`. ### The player's capsule: `useCharacterBody` ```tsx const state = useRef(createCharacterState(spawn)); const playerBody = useCharacterBody(state, params); // a ref to the capsule's BodyId ``` Inside `<Physics>`, call it in the component that steps the player. A kinematic capsule mirrors the controller's state right before each step, with its velocity, so crates the player walks into are shoved. Options: `{ material?, name? }`. A kinematic body pushes any dynamic body whatever its mass, so things that must stop the player are static bodies or level boxes. `attachCharacter(world, params, options)` is the same thing without React. ### Hooks | Hook | What it does | | --- | --- | | `usePhysics()` | The nearest `<Physics>` world; throws outside one | | `usePhysicsOptional()` | The same, or `null` outside one | | `useCollisions(fn)` | Every `CollisionEvent` in the world, after each step | | `useTriggers(fn)` | Every `TriggerEvent` (enter and exit) | | `impactStrength(event, soft = 1, hard = 10)` | How hard a hit was, 0 to 1, from the closing speed in m/s | A `CollisionEvent` is `{ kind: 'collision', a, b, impulse, speed, point, normal }`, reported once per contact (a landing is one event), not per contact point. A `TriggerEvent` is `{ kind: 'trigger', trigger, other, phase: 'enter' | 'exit' }`. ## `PhysicsWorld` The world behind `<Physics>`, usable headless (tests, gameplay reads and pokes). `new PhysicsWorld({ gravity?, fixedStep?, maxSubsteps? })`. | Method | What it does | | --- | --- | | `addBody(def)`, `removeBody(id)`, `has(id)`, `kindOf(id)` | Bodies by `BodyDef` (`kind: 'static'`, `'dynamic'` or `'kinematic'`) | | `positionOf(id)`, `velocityOf(id)`, `quaternionOf(id)`, `isSleeping(id)` | Exact state after the last step | | `transformOf(id)` | Interpolated for rendering between the last two steps | | `setTransform(id, position, quaternion?)` | Teleport; wakes it | | `moveKinematic(id, position, velocity, quaternion?)` | Move a kinematic body for this frame, before the step | | `applyImpulse(id, impulse, worldPoint?)` | An instant kick in N·s | | `applyForce(id, force, worldPoint?)` | A force in N for this step; call every frame while it acts | | `setVelocity(id, velocity, angular?)` | | | `raycast(from, to, options?)` | Nearest hit `{ body, point, normal, distance }` or `null` | | `raycastAll(from, to, options?)` | Every hit, nearest first | | `addJoint(def)`, `removeJoint(id)`, `hasJoint(id)`, `hingeAngle(id)` | Joints | | `onCollision(fn)`, `onTrigger(fn)` | Subscribe; returns the unsubscribe | | `step(dt)` | Advance by real seconds in fixed steps (`<Physics>` calls it) | | `stepFixed(n)` | Exactly `n` fixed steps (tests) | | `drainEvents()`, `dispatchEvents()` | The event queue (`<Physics>` dispatches once per frame) | | `stats` | `{ bodies, awake, joints, stepMs, substeps }` | | `dispose()` | Release everything | Ray options: `filter(body)` to skip bodies, `skipTriggers` (default true). `fromLevel(world, level)` is what `LevelBodies` calls; it returns the ids it added. ## `Vehicle` A drivable car: a chassis on four sprung raycast wheels that drives, slides, drifts with the handbrake, flies off ramps, lands and rights itself after a while on its roof. It hits every body in the world. Use it for driving games that are not lap races; the racing module keeps its own arcade car. ```tsx import React, { useMemo, useRef } from 'react'; import { Physics, Vehicle, impactStrength, shake, type VehicleBody, type VehicleInput } from '@/engine'; export const Buggy = () => { const input = useMemo<VehicleInput>(() => ({ throttle: 0, steer: 0 }), []); // your controls write it const car = useRef<VehicleBody | null>(null); return ( <Physics> <Vehicle position={[0, 1, 0]} input={input} color="#FF5C3A" onVehicle={(v) => (car.current = v)} onLand={(airTime) => shake(Math.min(0.6, airTime * 0.4))} onCollide={(event) => { if (impactStrength(event) > 0.3) shake(0.3); }} /> </Physics> ); }; ``` | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `position` | `[x, y, z]` or `Vec3` | required | Where it starts; later changes are ignored (call `reset`) | | `yaw` | `number` | `0` | Heading in radians, 0 faces +z | | `input` | `VehicleInput` or `(state, dt) => VehicleInput` | required | `throttle` -1 to 1, `steer` -1 to 1 (positive toward +x), `brake?` 0 to 1, `handbrake?` | | `children` | `React.ReactNode` | a body and cabin | The chassis look, in chassis space (nose toward +z) | | `color`, `wheelColor` | `string` | `'#E8ECF4'`, `'#15161C'` | Default look | | `showWheels` | `boolean` | `true` | Hide them when your model has its own | | `onVehicle` | `(vehicle: VehicleBody) => void` | none | The vehicle: `state`, `rideHeight`, `wheelPoses()`, `reset(position, yaw?)` | | `onCollide` | `(event, other) => void` | none | The chassis hit something | | `onLand` | `(airTime: number) => void` | none | Landed after `airTime` seconds in the air | | `chassis` | `{ w?, h?, d?, mass? }` | 1.8 by 0.5 by 3.6, 150 kg | | | `wheel` | `{ radius?, halfTrack?, halfBase?, suspensionRest?, stiffness?, friction?, rollInfluence?, mount? }` | | `friction` is grip, about the g the tyres hold | | `engine` | `{ force?, brake?, coast?, maxSteer?, maxSpeed? }` | | Drive force per rear wheel, brake force, engine braking, steering in radians, top speed in m/s | | `centerOfMassDrop` | `number` | | Ballast: higher stops tipping | | `downforce` | `number` | `0` | Grip at speed | | `recoverAfter` | `number` | `1.2` | Seconds on its roof before it rights itself; 0 turns it off | `VehicleBody.state` has `speed`, `yaw`, `steer`, `spin`, `wheelsDown`, `airborne`, `airTime`, `landed`, `flipped`, `recoveries`. Headless: `createVehicle(world, def)`, then `drive(input, dt)` before each step. ## `Debris` Many small pieces on the GPU that bounce off the world's static boxes and a few moving bodies. They settle, sleep and shrink away at the end of their life. Bursts reuse the oldest pieces when `count` is full. ```tsx import React, { useRef } from 'react'; import { Debris, type DebrisHandle } from '@/engine'; export const Coins = () => { const coins = useRef<DebrisHandle>(null); // later, from gameplay: coins.current?.burst({ x: 0, y: 1, z: 0 }, { x: 0, y: 1, z: 0 }, { count: 20 }); return <Debris ref={coins} preset="coins" count={200} />; }; ``` | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `preset` | `'crash'`, `'coins'`, `'shells'`, `'glass'`, `'hail'`, `'confetti'` | `'crash'` | The look and behaviour | | `count` | `number` | `300` | Pieces alive at once | | `proxies` | body ids or refs to them | `[]` | Moving bodies the pieces bounce off (the car, the player's capsule) | | `groundY` | `number` | `0` | The ground when there is no `<Physics>` world to read boxes from | | `timeScale` | `number` | `1` | | | `source` | `{ at, direction, rate, area? }` | none | A continuous source: `rate` pieces per second (hail from a cloud) | | any `DebrisConfig` field | | from preset | `burst`, `speed`, `spread`, `size`, `sizeJitter`, `life`, `fade`, `gravity`, `restitution`, `friction`, `drag`, `spin`, `spinDamping`, `sleepSpeed`, `color`, `mesh` (`'chunk'`, `'coin'`, `'shard'`, `'ball'`) | The handle has one method: `burst(at, direction, { count?, speed?, spread? })`. `DEBRIS_PRESETS` holds the presets. Colliders are fixed-size GPU arrays: at most 96 static boxes and 16 moving spheres. ## `PhysicsDebug` A development-only line (bodies awake and total, joints, step time, substeps) drawn outside the canvas: `<PhysicsDebug world={world} extra="..." />`. With `<EngineCanvas debug>`, `<Physics>` also adds a `physics` line to the debug overlay. Neither shows in production builds. ## Gotchas - Rigid bodies run in JavaScript, and Hermes on the phones has no JIT, so they are expensive. The template's physics skill measured 20 bodies at about 0.5 ms a step, while 200 toppling boxes cost 80 to 100 ms (the game drops to about 12 fps) and even 200 sleeping bodies cost 10 ms. Keep a level under about 60 bodies with 30 awake at once, let things sleep, and remove what left the level. - Quantity goes into `Debris`: thousands of pieces cost one draw call per `<Debris>`. - Trimeshes only on static bodies, and only when a box or a hull will not do. - Dynamic bodies should be direct children of the scene, not nested under moving groups. - A hinged gate swings only if no level box overlaps its leaf and it hangs a few centimetres above the floor (a leaf resting on the ground is pinned by friction). - `<Model collides="box">`, `"sphere"` or `"hull"` gives a generated model a body ([Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md)). - The world costs nothing when `<Physics>` is not rendered. Games without moving props do not need it. - For trigger areas that do not need bodies at all (checkpoints, "near the shop"), zones are cheaper ([Utilities](https://docs.moonspell.app/engine/utilities.md)). --- # Models and scenery Source: https://docs.moonspell.app/engine/models-and-scenery > Bundled .glb models through Model (sized, anchored, animated, with a fallback until the file is real), Scenery for one model placed many times with instancing, Billboard sprites in the 3D scene, SheetImage in the HUD, and the loaders behind them. The models folder (`src/engine/models/`, exported from `@/engine`) puts art files into the 3D scene: - `Model`: one bundled `.glb` (a character, a vehicle, a prop), scaled to a size you choose, with its clips, and a `fallback` drawn until the file is loaded or while it is still a placeholder. - `Scenery`: one static `.glb` placed many times (a street of buildings, a forest), drawn as instanced meshes so thirty buildings cost about what one does. - `Billboard`: one cell of a sprite sheet on a quad that turns to face the camera (crops, coins, lanterns as cut-outs). `SheetImage`, the same idea for the HUD, is in `@/engine/ui` ([HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)). The character's own mesh, `CharacterMesh`, wraps `Model` and is documented with the controller ([Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)). ## Smallest working example A lantern model with a box as its fallback. The game plays the same before the model exists, while it loads, and if it fails: ```tsx import React from 'react'; import { EngineCanvas, Environment, Model, preloadGlb } from '@/engine'; const LANTERN = require('../../assets/models/lantern.glb'); preloadGlb(LANTERN); // start loading while the title screen shows export const LanternScene = () => ( <EngineCanvas camera={{ position: [0, 2, 4] }}> <Environment preset="night" /> <Model src={LANTERN} size={0.6} position={[0, 0, 0]} fallback={ <mesh position={[0, 0.3, 0]}> <boxGeometry args={[0.4, 0.6, 0.4]} /> <meshStandardMaterial color="#F6C85F" /> </mesh> } /> </EngineCanvas> ); ``` 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<string, number>` | 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 `<Physics>`: 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 <Model src={require('../../assets/models/stall.glb')} size={3} position={[4, 0, -2]} collides="box" level={level} /> ``` 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 = () => ( <Scenery src={BROWNSTONE} items={row} fallback={row.map((item, i) => ( <mesh key={i} position={[item.position[0], (item.size?.[1] ?? 1) / 2, item.position[2]]}> <boxGeometry args={item.size ?? [1, 1, 1]} /> <meshStandardMaterial color="#8A6A5A" /> </mesh> ))} /> ); ``` | 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 }) => ( <Billboard source={CROPS} cols={4} rows={4} frame={stage} position={[x, 0, z]} size={0.9} face="y" /> ); ``` | 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<ParticlesHandle>(null); // from gameplay: sparks.current?.burst(60, [0, 0.3, 0]); return ( <EngineCanvas camera={{ position: [0, 2, 4] }}> <Environment preset="night" /> <Particles preset="fire" count={300} origin={[0, 0.1, 0]} /> <Particles preset="smoke" count={100} origin={[0, 0.9, 0]} /> <Particles ref={sparks} preset="sparks" count={300} emitting={false} ground={{ y: 0, bounce: 0.45 }} /> <pointLight position={[0, 0.6, 0]} color="#FFB454" intensity={4} distance={6} /> </EngineCanvas> ); }; ``` 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 `<Particles>`. | 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<Object3D>` | 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 <Particles preset="sparks" count={200} follow={carRef} emitting={boosting} /> <Particles preset="rain" count={2000} origin={[0, 8, 0]} spread={[12, 0.5, 12]} ground={{ y: 0 }} /> <Particles preset="vortex" count={80} origin={[3, 1.1, 2]} emitting={!taken} /> ``` ## `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<Object3D>` | 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 }> }) => ( <> <LivelyGround w={60} d={60} base="#5A9A3A" light="#6DAE44" /> <Grass area={{ w: 60, d: 60 }} count={6000} height={0.55} colorA="#5A9A2C" colorB="#E6C24E" trample={() => (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<WaterHandle>(null); // something fell in: water.current?.splash(2, -1); return <Water ref={water} x={0} z={0} w={12} d={8} color="#2F7FA8" ripples drops={3} />; }; ``` | 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<number>` | 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 `<Particles>` is one draw call, so prefer one bigger system over many small ones. - `<PostFX preset="glow" />` 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<THREE.Mesh>(null); const onHit = () => juice('hit', { object3d: enemy.current ?? undefined }); return ( <View style={{ flex: 1 }}> <EngineCanvas> <GameFeel /> <mesh ref={enemy} position={[0, 1, 0]}> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="#FF5C7A" /> </mesh> </EngineCanvas> <FeelOverlay /> <Pressable accessibilityRole="button" onPress={onHit} style={{ position: 'absolute', bottom: 48, alignSelf: 'center', padding: 16 }}> <Text style={{ color: '#FFFFFF', fontWeight: '800' }}>HIT</Text> </Pressable> </View> ); }; ``` `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). `<GameFeel shake={{ maxOffset: 0 }} />` 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 ( <View style={styles.root}> <GameCanvas2D background="#14102A" fonts={FONTS} onStep={(dt) => { 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); }} /> <View style={StyleSheet.absoluteFill} pointerEvents="box-none"> <TopBar title="Tap Demo" status="Tap anywhere" onExit={onExit} /> </View> </View> ); }; 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<PointerThresholds>` | `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<ViewStyle>` | `{ 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'; <GameCanvas2D stage={{ width: 360, height: 640, fit: 'contain' }} onRender={render} /> ``` `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 }; // <GameCanvas2D sheets={[TILES]} onRender={render} /> ``` 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 `<GameCanvas2D pixelArt>` 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'; <GameCanvas2D pixelArt sheets={SHEETS} onReady={(ctx) => (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<number>(); 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 ( <View style={{ flex: 1 }}> <GameCanvas2D background="#1B1440" onStep={onStep} onRender={onRender} /> <CharacterControls input={input} look={false} /> </View> ); }; 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<CharacterHandle>(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 ( <> <Environment preset="sunset" follow={() => body.current} /> <LevelMeshes level={LEVEL} /> <CharacterMesh ref={mesh} color="#7C5CFF" radius={PARAMS.radius} height={PARAMS.height} /> <CharacterCamera rig={RIG} target={body} level={LEVEL} bodyHeight={PARAMS.height} speedRatio={() => body.current.speed / PARAMS.runSpeed} /> </> ); }; const Hop = ({ onExit }: GameScreenProps) => { const input = useMemo(() => createCharacterInput(), []); const body = useRef<CharacterState>(createCharacterState(LEVEL.spawns[0])); return ( <View style={styles.root}> <EngineCanvas style={StyleSheet.absoluteFill} background={LEVEL.theme.sky}> <Simulation input={input} body={body} /> </EngineCanvas> <CharacterControls input={input} /> <View style={StyleSheet.absoluteFill} pointerEvents="box-none"> <TopBar title="Hop" status="Reach the green pad" onExit={onExit} /> </View> </View> ); }; 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<LevelTheme>` | | `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 `<LevelMeshes level={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 = () => ( <> <Extruded outline={[[0, 0], [4, 0], [4, 3], [0, 3]]} height={2.5} position={[8, 0, 8]} /> <Lathed profile={[[0.6, 0], [0.4, 1.2], [0.7, 2]]} position={[-6, 0, 4]} /> </> ); ``` Generated `.glb` props can join the level as hidden boxes with `<Model collides="box" level={level}>`. 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<ResolvedPoint>`; 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<string, number>` | 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<CharacterHandle | null> }) => ( <CharacterMesh ref={handle} color="#7C5CFF" radius={params.radius} height={params.height} model={require('../../assets/models/hero.glb')} drive={body} speeds={{ walk: params.walkSpeed, run: params.runSpeed }} onAnimationEvent={(e) => { 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 }) => ( <View style={StyleSheet.absoluteFill} pointerEvents="box-none"> <CharacterControls input={input} buttons={BUTTONS} disabled={over} /> </View> ); ``` | 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-<id>` 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, `<Joystick input={input} />` 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 `<Ripples>` 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. `<Flock>` 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<RipplesHandle>(null); // after a hard landing: rings.current?.play(s.x, s.y, s.z, 0.8); return ( <> <Ripples ref={rings} /> <Flock perches={[[2, 1.2, 6], [2.4, 1.2, 6.3], [1.8, 1.2, 6.5]]} near={() => 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<string, Gunner> = {}; 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<typeof makeMatch>; 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<CharacterHandle>(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 ( <> <ambientLight intensity={0.5} /> <directionalLight position={[-30, 60, -20]} intensity={1} /> <LevelMeshes level={LEVEL} /> <CharacterMesh ref={mesh} color="#7C5CFF" radius={PARAMS.radius} height={PARAMS.height} /> <BotMeshes bots={squad.bots} radius={PARAMS.radius} height={PARAMS.height} /> <CombatEffects world={world} now={() => clock.current} numbersOut={numbers} /> <CharacterCamera rig={rig} target={body} level={LEVEL} bodyHeight={PARAMS.height} /> </> ); }; const Arena = ({ onExit }: GameScreenProps) => { const input = useMemo(() => createCharacterInput(), []); const numbers = useMemo<ScreenNumber[]>(() => [], []); const [match] = useState(makeMatch); const [hud, setHud] = useState<Hud | null>(null); return ( <View style={styles.root}> <EngineCanvas style={StyleSheet.absoluteFill} background={LEVEL.theme.sky}> <Simulation match={match} input={input} numbers={numbers} onHud={setHud} /> </EngineCanvas> <CharacterControls input={input} buttons={BUTTONS} /> <View style={StyleSheet.absoluteFill} pointerEvents="box-none"> <TopBar title="Arena" status="Free-for-all" rightLabel="KILLS" rightValue={String(hud?.kills ?? 0)} onExit={onExit} /> {hud && ( <> <Crosshair spreadDeg={hud.spread} locked={hud.locked} /> <DamageNumbers source={numbers} onlyBy="player" /> <Killfeed entries={hud.feed} /> <HealthBar hp={hud.hp} max={hud.max} /> <AmmoCounter ammo={hud.ammo} magazine={hud.magazine} reloading={hud.reloading} weapon={hud.weapon} /> </> )} </View> </View> ); }; 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 `<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 `<DamageNumbers>`. | ## 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 `<BotMeshes>`. 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<CharacterHandle>(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 ( <> <ambientLight intensity={0.5} /> <directionalLight position={[-30, 60, -20]} intensity={1} /> <LevelMeshes level={LEVEL} /> <CharacterMesh ref={mesh} color="#7C5CFF" radius={PARAMS.radius} height={PARAMS.height} /> <BotMeshes bots={squad.bots} radius={PARAMS.radius} height={PARAMS.height} /> <CharacterCamera rig={rig} target={body} level={LEVEL} bodyHeight={PARAMS.height} /> </> ); }; const Tag = ({ onExit }: GameScreenProps) => { const input = useMemo(() => createCharacterInput(), []); const body = useRef<CharacterState>(createCharacterState({ x: 0, y: 0, z: -19, yaw: 0 })); const squad = useMemo(() => makeSquad(), []); return ( <View style={styles.root}> <EngineCanvas style={StyleSheet.absoluteFill} background={LEVEL.theme.sky}> <Simulation input={input} body={body} squad={squad} /> </EngineCanvas> <CharacterControls input={input} /> <View style={StyleSheet.absoluteFill} pointerEvents="box-none"> <TopBar title="Tag" status="Do not get caught" onExit={onExit} /> </View> </View> ); }; 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 `<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 ( <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}> <Text>Coins: {data.coins}</Text> <Pressable onPress={() => save.update((s) => { s.coins += 10; })}> <Text>Earn 10</Text> </Pressable> <Pressable onPress={onExit}> <Text>Exit</Text> </Pressable> </View> ); }; 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<T>` 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 `<key>.<slot>.corrupt`, and the game starts from the defaults. - A migration that throws keeps the old save under `<key>.<slot>.v<old version>` 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 `<key>.<slot>.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<number, (old) => 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<T>` | 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<void>` | 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 ( <Pressable onPress={() => setSettings({ sound: !settings.sound })}> <Text>Sound: {settings.sound ? 'on' : 'off'}</Text> </Pressable> ); }; // 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<ScoreCardRef>(null); return ( <View> <ScoreCard ref={card} title="Sky Runner" score={score} label="METRES" best={best} newBest={score >= best} lines={['Day 4 streak']} colors={{ background: '#0b1020', text: '#ffffff', accent: '#ffcc4d' }} /> <Pressable onPress={() => shareScore(card, { message: `I ran ${score} m in Sky Runner. Beat that?` })}> <Text>Share</Text> </Pressable> </View> ); }; ``` | `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 `<key>__<slot>` (`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 ( <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }}> {presence.status !== 'local' && <Text>{presence.count} playing now</Text>} <Text>Best: {board.you?.score ?? 0}</Text> <Pressable onPress={() => setTaps((n) => n + 1)}> <Text>Tap ({taps})</Text> </Pressable> <Pressable onPress={() => void board.submit(taps).then(() => setTaps(0))}> <Text>Submit</Text> </Pressable> {board.entries.map((e) => ( <Text key={`${e.player}-${e.rank}`}> {e.rank}. {e.name} {e.score} </Text> ))} <Pressable onPress={onExit}> <Text>Exit</Text> </Pressable> </View> ); }; 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<CarState, 'honk'>(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) => <Ghost key={p.id} get={() => 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 ( <View style={{ flex: 1, backgroundColor: '#2E3B2A' }}> {objective && ( <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12 }}> <Text style={{ color: '#fff' }}> {objective.text}: {objective.done} of {objective.target} </Text> <Pressable onPress={harvest}> <Text style={{ color: '#fff' }}>Harvest</Text> </Pressable> </View> )} {!beat && ( <Pressable style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }} onPress={onExit}> <Text style={{ color: '#fff' }}>The end. Tap to exit.</Text> </Pressable> )} {beat?.kind === 'title' && ( <Pressable style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }} onPress={() => setState((s) => advance(STORY, s))}> <Text style={{ color: '#fff', fontSize: 28 }}>{beat.text}</Text> {beat.sub ? <Text style={{ color: '#fff' }}>{beat.sub}</Text> : null} </Pressable> )} {beat?.kind === 'say' && ( <DialogueBox speaker={speakerOf(STORY, beat.speaker)} text={beat.text} onAdvance={() => setState((s) => advance(STORY, s))} onSkip={() => setState((s) => skipDialogue(STORY, s))} /> )} {beat?.kind === 'choice' && ( <ChoiceCards title={beat.title} options={beat.options} confirm={beat.confirm} onChoose={(id) => setState((s) => choose(STORY, s, id))} /> )} {beat?.kind === 'reward' && ( <RewardSheet title={beat.title ?? 'Reward'} items={beat.items.map((it) => ({ label: it.resource, amount: it.amount }))} button="Take it" onDone={() => setState((s) => advance(STORY, s))} /> )} </View> ); }; 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-<id>`, `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<Ttt, number> = { 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 }) => ( <> <CameraDolly path={ORBIT} active={onTitle} speed={4} lookAt={{ x: 0, y: 0, z: 0 }} /> <PathFollow path={PATROL} speed={2} mode="pingpong" pause={0.5}> <mesh position={[0, 0.5, 0]}> <boxGeometry args={[1, 1, 1]} /> <meshStandardMaterial color="#FFB454" /> </mesh> </PathFollow> </> ); ``` ### `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<Events>(); // 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 }) => ( <EngineCanvas debug="top-right"> <EnemyCount alive={alive} /> </EngineCanvas> ); ``` | Export | What it does | | --- | --- | | `<EngineCanvas debug>` | `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 `<iframe>`. `src/engine/shellModule.ts` declares what a shell may give: ```ts // Simplified from src/engine/shellModule.ts (the real type overloads addListener per event). type ShellModule = { exitToShell?: () => void; isActive?: () => boolean; addListener?: (event: 'onShellClosed' | 'onShellActive', listener: (event?: { active: boolean }) => void) => { remove: () => void }; launchURL?: string | null; // the link the game was opened with shareURL?: string | null; // the game's public share link }; ``` Every field is optional, and the engine reads it with optional chaining, so a missing shell or a missing field is never an error. Games use it through `@/engine`: | Export (`shell.ts`) | What it does | With no shell | | --- | --- | --- | | `exitToShell()` | Leave the game and go back to the app's games list | Does nothing on the phones | | `onShellClosed(listener)` | The app closed the game; the template's `App.tsx` unmounts the game so its sound, simulation and rendering stop | Never fires | | `isShellActive()` | Whether the game is on screen (the app's feed keeps the next games loaded and paused) | `true` | | `onShellActive(listener)` | Fires right away with the current state, then on every change. Listeners must tolerate the same state twice | Fires once with `true` | | `useShellActive()` | `isShellActive()` as React state | `true` | The engine already pauses itself while the game is not active: `EngineCanvas` stops its frame loop, `GameCanvas2D` stops stepping, and the audio mixer and `RaceAudio` go quiet. Use `useShellActive()` only for game code that runs outside those loops (your own timers, sounds outside the mixer). The launch and share links are read by the net module (`getLaunchUrl`, `getShareUrl`, `useLaunchRoomId`; see [Net](https://docs.moonspell.app/engine/net.md)). The template's `App.tsx` is where this is wired: it renders `game.Component` with an `onExit` that unmounts the game and calls `exitToShell()`, and subscribes to `onShellClosed`. When the game is not playing, it shows a "Play" button to start it again. Keep that contract in your game repo. ### On the phones `shellModule.ts` calls `requireOptionalNativeModule('GenUXExpoModule')` from `expo`. That native module is compiled into the Moonspell app and its App Clip only. In any other build, including a normal Expo development build of your game (`npx expo run:ios`, `npx expo run:android`), it is absent and the call returns `null`. The game then runs on its own: always active, never closed by a shell, and the exit button does nothing beyond what your game does in `onExit`. If your game is the whole app, make the exit button do something useful there (open a menu, restart), or hide it. ### In a browser `shellModule.web.ts` implements the same contract with `postMessage` to `window.parent`. Messages are plain objects tagged `crescent: 'game'` (from the game) or `crescent: 'shell'` (from the app), and only messages from the parent window are read. | Direction | `type` | Meaning | | --- | --- | --- | | game to app | `ready` | The game started | | game to app | `error` (with `message`) | The game could not start | | game to app | `unsupported` (with `reason: 'webgpu'`) | No WebGPU in this browser | | game to app | `exit` | The player left the game | | game to app | `pinch`, `pinch-end` | A two-finger pinch (or trackpad pinch) is closing the game; `scale` goes from 1 to 0, `exit` says whether it ended past the threshold | | app to game | `closed` | Stop the game | | app to game | `active` (with `active`) | On screen or not | The links come from the page's query string: `?launch=...&share=...`, and `?active=0` starts the game paused. Opened on its own, not in a frame (a plain `expo start --web` or a static export you host), the game is active (unless the URL has `active=0`), `exitToShell()` goes back one page in the browser history when there is one, no messages are sent, and the pinch watcher is not installed. ## The web boot (`src/engine/web/`) `index.web.ts` calls `bootWeb(loadApp)`. In order: 1. It asks `navigator.gpu` for an adapter. If there is no `navigator.gpu`, or it returns no adapter, it shows "This browser can't run this game yet. Try the latest Chrome, Edge or Safari.", posts `unsupported` (`reason: 'webgpu'`) to the parent frame, and stops. The engine draws with WebGPU on every platform; there is no WebGL path. 2. It loads Skia's CanvasKit (`LoadSkiaWeb`), fetching `canvaskit.wasm` from the build's own folder (`webBase()`, the folder the page is served from). 3. It installs the system font fallback (below). 4. Only then it requires the game and registers it. Skia's web build reads CanvasKit the moment it is imported, so the game must not be evaluated earlier: this is why the entry uses `require()` inside the callback and not a top-level import. 5. If any step throws, it shows "This game could not start. Reload to try again." CanvasKit has no system fonts, and its `matchFamilyStyle` throws. `systemFont.ts` loads two of the engine's bundled faces (Nunito Bold, and Nunito Black from weight 800 up) and answers every `matchFont()` with them, so the engine's HUD, the 2D layer and any game that calls `matchFont` draw text in a browser with no change. A game that loads its own face with Skia's `useFont` gets exactly that face, as on the phones. `canvaskit.wasm` must be served next to the page. In the template, the `npm run web` and `npm run export:web` scripts run `setup-skia-web public` first, which copies it into `public/` (ignored by git). See [Web](https://docs.moonspell.app/platforms/web.md) for running and hosting a web build. ## Gotchas - No dynamic `import()` in a game. The web bundle is one file so it can be served from any folder; use `require()` or static imports. - Never build a file path or URL by hand. A web build names every asset by its address, the phones by a module id; `require()` and the engine's loaders handle both. - Never pass a Reanimated `Keyframe` to `entering`: in a browser it pins the element out of its layout. Use `rise`, `land` and `leave` from `@/engine/ui`. - Skia text in a browser is Nunito unless you load a face yourself, so do not rely on a system font's exact metrics. - Two-finger gestures belong to the game on the phones. In a framed browser game, a two-finger pinch where both fingers move toward each other is read as closing the game; two thumbs resting on controls are not. - What the Moonspell app itself does with these messages (its feed, App Clips, share links) is part of the private app and is not documented here ([What is and is not open](https://docs.moonspell.app/start/open-source.md)). --- # Game modules Source: https://docs.moonspell.app/modules/overview > A game module is a whole genre packaged as one defineXGame call; this page explains how modules differ from engine pieces, lists the four modules and helps you pick one. A game module is a whole genre in one function. You call `defineRaceGame`, `definePuzzleGame`, `defineShooterGame` or `defineKingdomGame` with a config (numbers, colours, names, content tables) and get back a complete game: the rules, the scene, the controls, the HUD, the start and end screens, sounds where the module has them, and the dev self-play button. You write no game loop. Use a module when your idea fits its genre. When it does not, build the game from the engine pieces instead (see [When no module fits](https://docs.moonspell.app/modules/overview.md#when-no-module-fits)). ## Module or engine piece The [engine](https://docs.moonspell.app/engine/overview.md) is a set of pieces: a WebGPU canvas, touch controls, a character controller, a combat kit, bots, the 2D layer, saves, leaderboards and more. A game made from pieces writes its own loop in `src/game/index.tsx` and decides everything. A module is built from those same pieces, with the loop already written. The difference is what you write: | | Engine pieces | A game module | | --- | --- | --- | | What you write | a React component with your own loop, rules, HUD and screens | one config object | | Size of `src/game/index.tsx` | hundreds of lines | 10 to 300 lines, mostly data | | What you can change | anything | what the config exposes; more by editing the module's code | | Where it lives | `@/engine` | `@/engine/modules/<name>` | Modules are not re-exported from `@/engine`. Import each one from its own path: ```tsx import { defineRaceGame } from '@/engine/modules/racing'; ``` ## The four modules | Module | Genre | Entry function | What you supply | Example | | --- | --- | --- | --- | --- | | [Racing](https://docs.moonspell.app/modules/racing.md) | 3D arcade car race on a closed track | `defineRaceGame(config)` | laps, a track preset or your own points, car tuning, number and pace of AI rivals, time of day and colours | Sunset Sprint, the starter game in `src/game/index.tsx` | | [Puzzle](https://docs.moonspell.app/modules/puzzle.md) | 2D grid puzzle: block, merge, match or sort | `definePuzzleGame(config)` | the rule pack, endless or levels, a daily puzzle, board size, palette, optional tile and background painters | `examples/moon-blocks/`, `examples/moonrise/`, `examples/gem-cascade/` | | [Shooter](https://docs.moonspell.app/modules/shooter.md) | 3D arena shooter with bots and multiplayer | `defineShooterGame(config)` | a mode (`ffa`, `tdm`, `gun-game`, `ctf`, `koth`), rules, a map preset, loadouts, seats and bots, items | `examples/flag-rush/` | | [Kingdom](https://docs.moonspell.app/modules/kingdom.md) | story-led town builder with joystick fights | `defineKingdomGame(config)` | resources, buildings, crops, units, hero classes, missions, the town layout and a story of chapters | `examples/oath-of-ashvale/` | Every entry function returns a `GameDefinition` (`id`, `title`, `description`, `badge`, `accent`, `Component`). Your `src/game/index.tsx` default-exports it, and the app renders its `Component` full screen. The rules for that file are on [the game contract](https://docs.moonspell.app/start/game-contract.md) page. ```tsx // src/game/index.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', }); ``` Every module fills defaults and clamps out-of-range numbers, so a config with a wrong value still plays. Stay inside the documented ranges anyway, so the game does what the config says. ## How to choose | Your idea | Pick | | --- | --- | | cars, laps, a circuit, drifting, a kart race | Racing | | fill lines with blocks, slide and merge numbers, tap groups of a colour, swap three in a row, pour colours into tubes | Puzzle | | guns, bots, deathmatch, teams, capture the flag, king of the hill, a weapon ladder, friends in the same match | Shooter | | a village you build and grow, timers, crops, a hero who rides out to clear enemy camps, dialogue and chapters | Kingdom | | anything else | the engine pieces, see below | A few edge cases: - A shooter rule none of the five modes covers (waves of enemies, bomb defusal, escort): use the combat kit directly, starting from `examples/pulse-arena/`. - A puzzle mechanic beyond the four packs: either add a rule pack to the puzzle module (see [Writing a module](https://docs.moonspell.app/modules/writing-a-module.md) for the conventions) or draw the game yourself on the 2D layer. - A town builder with no story and no fights: the kingdom module requires a story. Without one, use [saves and progression](https://docs.moonspell.app/engine/saves.md) with your own screens. ## When no module fits Compose the engine pieces in `src/game/index.tsx`. Start from the [example](https://docs.moonspell.app/examples.md) closest to your idea and change it. The pieces you will most likely need: | You need | Read | | --- | --- | | a 3D scene, lights and the frame loop | [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md) | | a character that runs and jumps, cameras, levels | [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md) | | weapons, health, hits | [Combat](https://docs.moonspell.app/engine/combat.md) | | enemies that patrol, hear, see and chase | [Bots](https://docs.moonspell.app/engine/bots.md) | | a flat game: tiles, sprites, taps, particles | [2D layer](https://docs.moonspell.app/engine/2d.md) | | turn-based board games with a computer opponent | [Turns](https://docs.moonspell.app/engine/turns.md) | | dialogue, choices and objectives | [Story](https://docs.moonspell.app/engine/story.md) | | touch controls, a joystick, keys on the web | [Input](https://docs.moonspell.app/engine/input.md) | | a HUD, icons and fonts | [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md) | | sounds and music | [Audio](https://docs.moonspell.app/engine/audio.md) | | saved progress, currencies, unlocks | [Saves](https://docs.moonspell.app/engine/saves.md) | | leaderboards, presence, multiplayer rooms | [Networking](https://docs.moonspell.app/engine/net.md) | If you find yourself building the same genre twice, it may be worth a module of its own: [Writing a module](https://docs.moonspell.app/modules/writing-a-module.md). --- # Racing module Source: https://docs.moonspell.app/modules/racing > defineRaceGame builds a complete 3D arcade car race from one config, with a track, the player's car, AI rivals, a chase camera, a HUD, touch and keyboard controls, and a countdown and finish screen. `defineRaceGame(config)` makes a full-screen arcade race on a closed track: the player's car against up to six AI rivals, for a number of laps. Use it for any car racing game: circuits, ovals, karts, night races, desert rallies. The starter game every new repo ships with, **Sunset Sprint**, is this module. ```tsx import { defineRaceGame, TRACK_PRESETS } from '@/engine/modules/racing'; import type { RaceConfig } from '@/engine/modules/racing'; ``` Source: `templates/crescent-game/src/engine/modules/racing/`. ## The smallest complete game This is Sunset Sprint, the starter game in `templates/crescent-game/src/game/index.tsx` (the file there also sets a `badge`): ```tsx // src/game/index.tsx import { defineRaceGame } from '@/engine/modules/racing'; export default defineRaceGame({ id: 'sunset-sprint', title: 'Sunset Sprint', description: 'Three laps around a coastal circuit as the sun goes down.', laps: 3, track: { preset: 'circuit', width: 12, seed: 11 }, car: { color: '#7C5CFF', accent: '#2EE6D6', maxSpeedKmh: 160, acceleration: 1.1, handling: 1.1 }, opponents: { count: 3, speed: 0.8 }, theme: { timeOfDay: 'sunset', scenery: 'trees', fog: true }, }); ``` Only `id`, `title` and `description` are required. Everything else has a default. ## What the player gets - A 3D track built from the preset or your points: road, edge lines, a dashed centre line, alternating barriers and scenery placed by `track.seed`. - A "3, 2, 1, GO!" countdown, during which nobody moves. - The player's car, starting last on the grid, and AI rivals that follow the racing line, lift for corners and brake rather than drive through the player. - Laps that count only when the car has passed three checkpoints (at a quarter, half and three quarters of the lap). Reversing over the line clears them, so the line cannot be farmed. - A HUD: lap (`LAP 1/3`), position, race timer, a speed gauge, a mute button and an exit button. - A finish card with the position, the total time, the best lap, **Race again** and **Back to games**. - Engine sounds that follow the speed, tyre squeal, braking, bumps, a whoosh when a rival passes, and a music track. - Lighting, sky and fog from `theme.timeOfDay`, with car lights at night. Controls are the engine's `TouchControls`: steer left and right on one side, gas and brake on the other. In a desktop browser the same actions are on the keyboard: A / D or the left and right arrows steer, W or the up arrow is the gas, S, the down arrow or Space brake. See [Input](https://docs.moonspell.app/engine/input.md). ## Config reference Values outside a range are clamped. Speeds and ratios are multipliers unless stated. ### Top level | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | URL-safe, kebab-case id. | | `title` | `string` | required | The game's name, shown in the HUD. | | `description` | `string` | required | One sentence for the game's card. | | `badge` | `string` | a race car emoji | Card metadata; the race never shows it. | | `laps` | `number` | `3` | Laps to finish, 1 to 10. | | `track` | object | see below | The track shape and size. | | `car` | object | see below | The player's car. | | `controls` | object | see below | The control layout. | | `opponents` | object | see below | The AI rivals. | | `theme` | object | see below | Colours, scenery, light and post-processing. | ### `track` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `preset` | `'oval' \| 'figure-eight' \| 'hairpin' \| 'circuit' \| 'speedway'` | `'circuit'` | A built-in shape. | | `points` | `[number, number][]` | none | Your own closed loop as `[x, z]` in metres. Needs 4 or more points, otherwise the preset is used. Overrides `preset`. | | `width` | `number` | `11` | Road width in metres, 8 to 16. | | `seed` | `number` | `7` | Seed for scenery placement. | The presets, with their approximate size: | Preset | Size | Character | | --- | --- | --- | | `oval` | 140 x 84 m | fast and simple | | `speedway` | 240 x 120 m | very fast, long straights | | `figure-eight` | about 160 m across | crosses itself in the middle | | `hairpin` | about 225 x 55 m | two long straights, tight ends | | `circuit` | about 205 x 120 m | varied corners, the default | `TRACK_PRESETS` exports the point lists, so you can start from one and edit it. ### `car` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `color` | CSS colour | `'#7C5CFF'` | Body colour. | | `accent` | CSS colour | `'#2EE6D6'` | Cabin colour. Also the HUD accent, the **Race again** button and the game's `accent`. | | `maxSpeedKmh` | `number` | `150` | Top speed in km/h, 80 to 220. | | `acceleration` | `number` | `1` | Engine force, 0.5 (sluggish) to 2 (rocket). | | `handling` | `number` | `1` | Steering response, 0.5 (boat) to 2 (go-kart). Higher also means less understeer. | | `steeringAssist` | `number` | `0.12` | 0 to 1. Gently steers toward the racing line while the player is not steering. 0 is none, 0.5 and up is very easy. | ### `controls` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `layout` | `'classic' \| 'mirrored'` | `'classic'` | `classic` puts steering on the left and pedals on the right; `mirrored` swaps them. | ### `opponents` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `count` | `number` | `3` | AI cars, 0 to 6. | | `colors` | `string[]` | six bright colours | Body colours, cycled when shorter than `count`. | | `speed` | `number` | `0.62` | Pace as a fraction of the player's top speed, 0.4 to 1.1. 0.62 is beatable, 0.75 a fair fight, 0.9 hard. Each rival varies by a few percent so the pack spreads out. | ### `theme` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `timeOfDay` | `'day' \| 'sunset' \| 'night'` | `'day'` | Picks the lighting and the default sky, ground, road and line colours. Night turns on car lights. | | `sky` | CSS colour | per time of day | Sky and fog colour. | | `ground` | CSS colour | per time of day | Ground colour. | | `road` | CSS colour | per time of day | Road colour. | | `line` | CSS colour | per time of day | Edge line colour. | | `barrier` | `[string, string]` | `['#E63946', '#F8F9FA']` | Alternating barrier colours. | | `scenery` | `'trees' \| 'rocks' \| 'cacti' \| 'pillars' \| 'none'` | `'trees'` | What stands around the track. | | `sceneryColor` | CSS colour | `'#2F7D4A'` (`'#1F4D3A'` at night) | Scenery colour. | | `fog` | `boolean` | `true` | Distance fog in the sky colour. | | `postfx` | `'glow' \| 'cinematic' \| 'retro' \| 'off'` | `'off'` | A post-processing look. `glow` makes lights and neon bloom (night and arcade races), `cinematic` is moodier, `retro` punchier. | ## A custom track ```tsx import { defineRaceGame } from '@/engine/modules/racing'; export default defineRaceGame({ id: 'square-loop', title: 'Square Loop', description: 'A rounded square at night with neon barriers.', track: { points: [[-70, -70], [0, -85], [70, -70], [85, 0], [70, 70], [0, 85], [-70, 70], [-85, 0]], width: 12 }, theme: { timeOfDay: 'night', scenery: 'pillars', barrier: ['#FF2BD6', '#1B1E3A'], postfx: 'glow' }, controls: { layout: 'mirrored' }, }); ``` The loop closes itself and a spline smooths the corners. Shapes that drive well: - 6 to 20 points, roughly 150 to 400 m across. - Consecutive points 15 to 60 m apart. - No turn sharper than about 100 degrees. - The loop does not cross itself unless you want a crossing. ## How the car feels An arcade bicycle model. Leaving the road slows the car. Barriers are a hard wall: the car scrubs speed and slides along them, and never crosses. Cars collide: the player is pushed out of a rival and loses speed, more for a head-on hit. ## What you can customise | What | How | | --- | --- | | Look | `theme` colours, `scenery`, `timeOfDay`, `postfx`, `car.color` and `accent`, `opponents.colors`. | | Difficulty | `opponents.count` and `speed`, `laps`, `car.steeringAssist`, `track.width`. | | Feel | `car.maxSpeedKmh`, `acceleration`, `handling`. | | Sounds | The module plays fixed files: `assets/audio/engine-idle.wav`, `engine-high.wav`, `squeal.wav`, `accelerate.m4a`, `brake.m4a`, `bump.m4a`, `passby.m4a` and `music-synthwave.m4a`. Replace a file to change its sound. There is no `sounds` field. | | Car model | The cars are a procedural low-poly model; there is no field for a `.glb`. | | HUD, rules, anything else | Edit the game's own copy of the module in `src/engine/modules/racing/` (`RaceGame.tsx` is the screen, `RaceHud.tsx` the HUD, `raceState.ts` the lap rules, `vehicle.ts` the physics). Each game repo carries its own engine copy. Or write your own race from the engine pieces: `TouchControls`, `Environment`, `RaceAudio` and the [path utilities](https://docs.moonspell.app/engine/utilities.md) are exported from `@/engine`. | ## Backend The racing module uses no backend: no leaderboard, no rooms, no saves. To put a race online, add the calls yourself in your copy of `RaceGame.tsx`: - Best times: `useLeaderboard` with `order: 'asc'`, submitting milliseconds, one board per track. Online, the backend's `BOARDS` table decides the order; a family such as `"lap-*": { order: "asc", min: 5_000, max: 3_600_000 }` covers every track. - Live races: `useRoom`, sending the car's pose and drawing the other cars from the room. - Unlocked cars and tracks: [saves and progression](https://docs.moonspell.app/engine/saves.md). See [Networking](https://docs.moonspell.app/engine/net.md) and the [backend overview](https://docs.moonspell.app/backend/overview.md). ## Dev and testing In development builds (`__DEV__`) the HUD shows a telemetry line (`testID` `hud-debug`: inputs, speed, position on the track, collisions, sound counters, touches) and an **AUTO** button (`btn-autopilot`) that drives the car by itself. Production builds hide both. Other `testID`s: `btn-exit`, `btn-mute`, `hud-lap`, `hud-position`, `hud-countdown`, `hud-speed`, `hud-finished`, `btn-restart`, `btn-finish-exit`, and `game-<id>` on the root view. ## Gotchas - A custom `points` list with fewer than 4 points is ignored without a warning; the preset is used. - Clamping is silent: `laps: 20` plays 10 laps. - `opponents.speed` above 0.9 makes rivals very hard to beat; 1.1 is unfair on purpose. - The `badge` is card metadata. Keep it out of the game's own UI. - **Race again** remounts the 3D canvas, so anything you add to the scene restarts with it. --- # Puzzle module Source: https://docs.moonspell.app/modules/puzzle > definePuzzleGame builds a complete 2D grid puzzle from one config, with four rule packs (block, merge, match, sort), endless or level play, a daily puzzle, every screen, sounds, haptics and a leaderboard. `definePuzzleGame(config)` makes a whole grid puzzle on the engine's [2D layer](https://docs.moonspell.app/engine/2d.md): the rules, the board, input, animation, sounds, haptics, a title screen with the board playing itself behind it, the HUD, pause, results with a leaderboard and a share card, numbered levels with stars, a daily puzzle with a streak, and saved progress. Use it for one-thumb puzzle games in portrait. ```tsx import { definePuzzleGame } from '@/engine/modules/puzzle'; import type { PuzzleConfig, TilePainter, BackgroundPainter } from '@/engine/modules/puzzle'; ``` Source: `templates/crescent-game/src/engine/modules/puzzle/`. ## The four rule packs | `pack` | The game | Input | The round ends when | | --- | --- | --- | --- | | `block` | drag pieces from a tray onto the board; full rows and columns clear | drag | no piece in the tray fits | | `merge` | sliding number tiles: every tile slides, two equal tiles merge into the next value | swipe | no swipe changes the board, or `merge.target` is reached | | `match` | `input: 'tap'`: tap a group of one colour to pop it. `input: 'swap'`: swap two neighbours to line up three, with cascades | tap or drag | no move is left after shuffles; in level mode, the moves run out | | `sort` | pour colours between tubes until each tube holds one colour | tap a tube, then another | every tube is one colour (every deal is solvable) | ## The smallest complete game ```tsx // src/game/index.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', }); ``` That is an endless block puzzle on an 8 x 8 board. Change `pack` to `'merge'`, `'match'` or `'sort'` for the other games. `examples/moon-blocks/` is this game with a night-sky theme. ## A themed game with levels and drawn tiles ```tsx import { definePuzzleGame, type BackgroundPainter, type TilePainter } from '@/engine/modules/puzzle'; import { orbitronBlack } from '@/engine/ui/fonts/orbitronBlack'; import { orbitronBold } from '@/engine/ui/fonts/orbitronBold'; const paintTile: TilePainter = (draw, tile, x, y, size, theme) => { const cx = x + size / 2; const cy = y + size / 2; draw.disc(cx, cy, size * 0.4, { color: theme.palette[tile.kind % theme.palette.length] }); }; const paintBackground: BackgroundPainter = (draw, width, height, time) => { for (let i = 0; i < 30; i++) { const x = ((i * 97) % 100) / 100; const y = ((i * 61) % 100) / 100; draw.disc(x * width, y * height, 1.2, { color: '#FFFFFF', alpha: 0.3 + 0.3 * Math.abs(Math.sin(time + i)) }); } }; export default definePuzzleGame({ id: 'star-merge', title: 'Star Merge', description: 'Merge stars into bigger stars.', pack: 'merge', mode: 'levels', merge: { spawnFour: 0.08 }, levels: { budget: 'moves', difficulty: 1 }, theme: { background: '#0A0B1E', names: ['Spark', 'Ember', 'Star'], paintTile, paintBackground }, fonts: { display: orbitronBlack, ui: orbitronBold }, hint: 'Swipe the sky. Two matching stars become the next one.', }); ``` `examples/moonrise/` (merge, levels, twelve drawn bodies) and `examples/gem-cascade/` (match in swap mode, six gem cuts) are complete versions of this pattern. ## Config reference Every number is rounded and clamped into its range. ### Top level | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | URL-safe id. Also seeds the level table and the daily board. | | `title` | `string` | required | Shown on the title screen. | | `description` | `string` | `''` | One sentence for the game's card. | | `badge` | `string` | an emoji per pack | Card metadata; the game never shows it. | | `pack` | `'block' \| 'merge' \| 'match' \| 'sort'` | required | The rules. An unknown value falls back to `block`. | | `mode` | `'endless' \| 'levels'` | `'endless'` | High-score play, or numbered levels with a goal, a budget and stars. | | `daily` | `boolean` | `false` | Everyone gets the same board each day, seeded by the date, with a daily streak and a per-day leaderboard. | | `seed` | `number` | random per round | A fixed board, for tests or an authored puzzle. | | `board` | `{ rows?, cols? }` | per pack | Board size, see below. `sort` ignores it. | | `block` | object | see below | Block pack options. | | `merge` | object | see below | Merge pack options. | | `match` | object | see below | Match pack options. | | `sort` | object | see below | Sort pack options. | | `levels` | object | see below | Level mode options. | | `theme` | `PuzzleTheme` | night blue | The look, see below. | | `sounds` | `Partial<Record<PuzzleSound, AudioSource>>` | placeholder sounds | Replace any sound with your own `require(...)`, see [Sounds](https://docs.moonspell.app/modules/puzzle.md#sounds). | | `fonts` | `{ display?: GameFont; ui?: GameFont }` | Fredoka bold and semibold | Faces from `@/engine/ui/fonts`. Display is used for the title, score and tile numbers, UI for buttons and labels. Use two weights of one family. | | `hint` | `string` | one line per pack | Shown under the title and as the first-run hint. | ### `board` | Pack | Default | Range | | --- | --- | --- | | `block` | 8 x 8 | rows and cols 6 to 10 | | `merge` | 4 x 4 | rows and cols 3 to 6 | | `match` | 8 rows x 7 cols | rows 6 to 10, cols 5 to 9 | For `block` and `merge`, `cols` defaults to `rows`, so `board: { rows: 9 }` is a 9 x 9 board. ### `block` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `pieces` | `'classic' \| 'small' \| 'tetris' \| string[]` | `'classic'` | The shapes the tray deals: a named set from `PIECE_SETS`, or a list of names from `PIECES`. Unknown names are dropped. `small` is gentler, `tetris` is tetrominoes only. | | `tray` | `number` | `3` | Pieces per tray, 1 to 3. | | `colors` | `number` | `6` | Piece colours, 1 to the palette length. | | `fairTray` | `boolean` | `true` | Refill the tray so at least one piece fits whenever any piece could. | ### `merge` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `spawnFour` | `number` | `0.1` | Chance a new tile is a 4 instead of a 2, 0 to 0.5. | | `target` | `number` | `0` (endless) | Reaching this value wins the round. Snapped down to a power of two, 8 to 131072. | | `startTiles` | `number` | `2` | Tiles on the board at the start, 1 to 4. | ### `match` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `input` | `'tap' \| 'swap'` | `'tap'` | Pop groups by tapping, or swap neighbours to make lines of three, with cascades. | | `minCluster` | `number` | `2` for tap, `3` for swap | Smallest group a move clears, 2 to 4. | | `colors` | `number` | `5` | Colours on the board, 3 to 7. Fewer is easier. | ### `sort` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `colors` | `number` | `5` | Colours, one full tube each, 3 to 10. | | `capacity` | `number` | `4` | Units per tube, 3 to 6. | | `empty` | `number` | `2` | Spare empty tubes, 1 to 3. Fewer is harder. | | `undo` | `number` | `3` | Undos per round, 0 to 99. Only the sort pack has undo. | ### `levels` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `budget` | `'moves' \| 'seconds'` | `'moves'` | What runs out in level mode. | | `difficulty` | `number` | `1` | How fast levels get harder, 0.5 (gentle) to 2 (steep). | ### `theme` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `background` | CSS colour | `'#0F1024'` | Screen background. | | `board` | CSS colour | `'#1B1D3A'` | The panel behind the cells. | | `cell` | CSS colour | `'#262A4F'` | An empty cell. | | `text` | CSS colour | `'#F3F4FF'` | HUD text. | | `accent` | CSS colour | `'#2EE6D6'` | Buttons and highlights. Also the game's `accent`. | | `palette` | `string[]` | ten bright hues (merge: a warm ramp by value) | Tile colours by kind. For merge, one colour per value step (2, 4, 8 and so on). Give at least as many as the pack uses. Exported as `DEFAULT_PALETTE` and `MERGE_PALETTE`. | | `tileStyle` | `'glossy' \| 'flat' \| 'round'` | `'glossy'` | The default tile's look. | | `marks` | `boolean` | `false` | Draws a shape per colour (circle, triangle, square, diamond, star and more), so colour is never the only cue. | | `names` | `string[]` | none | A name per kind (merge: per value step), used in goals and results, for example "Reach Full Moon". | | `paintTile` | `TilePainter` | none | Your own tile art, see below. | | `paintBackground` | `BackgroundPainter` | none | An animated background behind the board, see below. | ### Painters Both painters draw with the 2D layer's `Draw2D` API (`disc`, `poly`, `line`, `ring`, `clip` and more, see [2D layer](https://docs.moonspell.app/engine/2d.md)) in stage units. | Type | Signature | Called | | --- | --- | --- | | `TilePainter` | `(draw, tile, x, y, size, theme) => void` | once per tile per frame. `tile` is `{ kind, value? }`; `(x, y)` is the tile's top-left corner and `size` its side. The module applies scale and fade around your drawing. | | `BackgroundPainter` | `(draw, width, height, time, theme) => void` | every frame behind the board. `time` is in seconds. | `theme` in both is the resolved theme, so `theme.palette` and `theme.accent` are always set. ## Levels and the daily puzzle `mode: 'levels'` generates numbered levels from the game's `id`, so a game always has the same levels: | Pack | Goal | Gets harder by | | --- | --- | --- | | `block` | Clear N lines | more lines, fewer spare moves | | `merge` | Reach a value (or its name from `theme.names`) | a higher value | | `match` | Clear N tiles | more tiles, more colours | | `sort` | Sort every tube | more colours | The budget is moves (or seconds with `levels.budget: 'seconds'`). Stars come from what is left: 3 stars with at least half the budget left, 2 with a fifth, otherwise 1. Sort levels are scored against the solver's par instead: 3 stars at par, 2 within one and a half times par. `daily: true` seeds the board from the date. Everyone who plays that day gets the same board, the streak grows once per day, and the leaderboard is that day's. Progress (best score, level reached, stars, streak) is kept in an engine save slot, so it survives app updates and syncs to the player's other devices when the game has a backend. Sound and vibration toggles in the pause menu are the engine's settings, see [Saves](https://docs.moonspell.app/engine/saves.md). ## Sounds The defaults are short synth placeholders in `assets/sfx/puzzle/`. The keys of `sounds` (`PuzzleSound`) and their default files: | Key | File | Plays on | | --- | --- | --- | | `place` | `place.wav` | a block placed | | `select` | `select.wav` | a tube picked, a swap | | `clear` | `clear.wav` | lines or tiles cleared | | `combo` | `combo.wav` | a combo or cascade | | `merge` | `merge.wav` | two tiles merged | | `pour` | `pour.wav` | a pour | | `invalid` | `invalid.wav` | an illegal move | | `levelUp` | `levelup.wav` | level mode: a first win of the newest level, just after `win`, as the next level opens (not on a replay) | | `win` | `win.wav` | the round won (solved or goal) | | `fail` | `fail.wav` | the round lost | ```tsx sounds: { merge: require('../../assets/sfx/my-merge.wav') }, ``` ## What you can customise | What | How | | --- | --- | | Rules and difficulty | `pack`, the pack's options, `board`, `mode`, `levels`, `daily`. | | Look | `theme` colours and `tileStyle`, `marks`, `fonts`. | | Tile art | `theme.paintTile`, drawn shapes, or cells of a sprite sheet drawn inside it. Avoid emoji and text characters as tile art. | | Background | `theme.paintBackground`. | | Words | `theme.names`, `hint`, `title`. | | Sounds | `sounds`. Haptics come with each event and follow the player's setting. | | A new rule | The packs are pure functions in `packs/` (`block.ts`, `merge.ts`, `match.ts`, `sort.ts`). Each implements `legalMoves`, `apply` (returns the new state, its events and the boards to animate through), `isOver` and `solve`. Add your rule there with a test. The screens (`PuzzleScreens.tsx`) and the controller (`controller.ts`) stay as they are. | The pure parts are exported for tests and tools: `initPuzzle`, `applyMove`, `solveMove`, `legalMoves`, `overReason`, `autoplay`, `levelFor`, `goalProgress`, `starsFor`, `budgetSpent`, `dailySeed`, `dateKey`, `resolvePuzzleConfig`, `PIECES`, `PIECE_SETS`, `PACK_HINTS`, `EFFECTS`, `DEFAULT_SOUNDS` and `drawMark`. ## Backend The module needs no backend code of its own. With `GAME_BACKEND_URL` set: - The results screen shows the live top five and your rank on one of three boards, all highest first: `endless` (score), `levels` (level reached) and `daily-YYYY-MM-DD` (that day's score). The backend scaffold's `BOARDS` already lists `endless`, `levels` and the `"daily-*"` family. - The title screen and the HUD show "N playing now" from presence. - Progress syncs through the player's save. Without a backend the same lists show this device's best runs. See the [backend overview](https://docs.moonspell.app/backend/overview.md) and [Networking](https://docs.moonspell.app/engine/net.md). ## Dev and testing `npm test` covers the packs, level generation, the daily seed and progress (`levels.test.ts`, `packs.test.ts`). In development builds, the title shows an **AUTO** button (`btn-autoplay`) that plays the solver, and the play screen shows a telemetry line with the pack, score, moves, legal moves, combo and the board geometry in pixels (`bpx` board rectangle, `cpx` cell pitch, tray slot or tube centres), so an automation tool can drag, swipe and tap. Other `testID`s: `puzzle-title`, `btn-play`, `btn-title-exit`, `hud-score`, `hud-status`, `hud-right`, `hud-hint`, `btn-pause`, `puzzle-pause`, `btn-resume`, `btn-sound`, `btn-haptics`, `btn-pause-exit`, `btn-undo`, `puzzle-results`, `hud-finished`, `hud-result-score`, `hud-leaderboard`, `btn-share`, `btn-restart`, `btn-finish-exit`. ## Gotchas - `palette` needs at least as many colours as the pack uses. `match.colors` and `sort.colors` are capped by the palette length, so a short palette quietly makes the game easier. - `merge.target` is snapped down: `target: 1000` becomes 512. - A `paintTile` that ignores `tile.kind` makes every colour look the same. Draw a different shape or colour per kind. - `seed` fixes every round to the same board. Leave it out for real play. - Keep `definePuzzleGame` as the default export; rebuilding the screens yourself loses the title, results, levels and AUTO. --- # Shooter module Source: https://docs.moonspell.app/modules/shooter > defineShooterGame builds a complete 3D arena shooter from one config, with five modes, rounds, three mirrored maps, items, bots in every empty seat, and multiplayer where one host phone runs the match. `defineShooterGame(config)` makes a whole arena shooter: five game modes (`ffa`, `tdm`, `gun-game`, `ctf`, `koth`), rounds with a countdown and a clock, three mirrored maps, items on the map, bots in every empty seat, and multiplayer where friends join the same match. Use it for any gun game played in an arena: deathmatch, team fights, capture the flag, king of the hill, a weapon ladder. ```tsx import { defineShooterGame } from '@/engine/modules/shooter'; import type { ShooterConfig, ShooterWeapon } from '@/engine/modules/shooter'; ``` Source: `templates/crescent-game/src/engine/modules/shooter/`. It is built on the engine's [character controller and cameras](https://docs.moonspell.app/engine/character-and-cameras.md), [combat kit](https://docs.moonspell.app/engine/combat.md) and [bots](https://docs.moonspell.app/engine/bots.md). ## The smallest complete game ```tsx // src/game/index.tsx import { defineShooterGame } from '@/engine/modules/shooter'; export default defineShooterGame({ id: 'yard-brawl', title: 'Yard Brawl', description: 'Everyone against everyone in an open courtyard. First to 15 wins.', }); ``` With every default, that is a free-for-all on the `hub` map: four seats (you and three bots), first to 15 kills or the most kills after five minutes, a rifle and a shotgun, health packs, armour, ammo crates and two weapons on the map. A fuller one is `examples/flag-rush/index.tsx`, three against three capture the flag: ```tsx import { defineShooterGame } from '@/engine/modules/shooter'; export default defineShooterGame({ id: 'flag-rush', title: 'Flag Rush', description: 'Three against three capture the flag. Bring their flag home, first to three wins.', mode: 'ctf', rules: { scoreToWin: 3, roundSeconds: 420, respawnSeconds: 3 }, map: { preset: 'depot', size: 24, seed: 5 }, player: { color: '#FF5C7A', loadout: ['rifle', 'shotgun'], camera: 'third' }, teams: { names: ['Ember', 'Tide'], colors: ['#FF5C7A', '#4FA8FF'] }, seats: 6, bots: { difficulty: 'normal', weapons: ['rifle', 'smg', 'shotgun'] }, pickups: { health: 50, armor: 50, ammo: true, weapons: ['rocket', 'sniper'] }, theme: { accent: '#FFD166' }, }); ``` ## What the player gets - A 3D arena with cover, an over-the-shoulder camera (or first person) and the engine's `CharacterControls`: a stick to move, the right half of the screen to look, **FIRE** (dragging it also turns the view, so one thumb aims and shoots), **JUMP**, and **SWAP** when the loadout has more than one weapon (never in gun game). In a desktop browser, WASD or the arrows move, Space fires, J, E or F jumps, K or Q swaps, and dragging the mouse on the look half turns the camera; see [Input](https://docs.moonspell.app/engine/input.md). - A countdown before each round, a round clock, the score strip (team scores or the free-for-all table), the objective line, announcements, a killfeed, a crosshair, a hit marker, damage numbers, health, armour and ammo. - Respawns after `respawnSeconds`, a round-over and match-over card with **Play again**. - A camera button (third or first person), an invite button for multiplayer, and an exit button. ## Modes | `mode` | Wins a round | `rules.scoreToWin` range, default | Teams | | --- | --- | --- | --- | | `ffa` free-for-all | most kills; a self-kill costs one | 3 to 50, 15 | no | | `tdm` team deathmatch | team kills | 5 to 100, 30 | two | | `gun-game` | every kill moves you up `rules.ladder`; a kill with the last weapon wins. A melee kill knocks the victim down a rung (`meleeDemotes`) | fixed: the ladder's length | no | | `ctf` capture the flag | bring the enemy flag to your base, by default only while your own flag is home. A carrier who dies drops the flag; a team-mate touching it sends it home, or it goes home after `flagReturnSeconds` | 1 to 10, 3 | two | | `koth` king of the hill | seconds spent holding the hill alone. Anyone else on it contests it and nobody scores | 20 to 300, 60 | two | When the clock runs out, the leader wins the round; a tie is a drawn round. ## Config reference Only `id`, `title` and `description` are required. Values outside a range are clamped. ### Top level | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | URL-safe, kebab-case. Also names the kills leaderboard. | | `title` | `string` | required | The game's name. | | `description` | `string` | required | One sentence for the game's card. | | `badge` | `string` | a target emoji | Card metadata; the game never shows it. | | `mode` | `ShooterMode` | `'ffa'` | One of the five modes. | | `rules` | object | see below | Score, clock, rounds and per-mode rules. | | `map` | object | see below | Map preset, size and cover. | | `player` | object | see below | Your body, loadout, health and camera. | | `teams` | object | Red and Blue | Names and colours of the two sides (team modes). | | `seats` | `number` | `4` (ffa, gun-game), `6` (team modes) | Humans plus bots, 2 to 8. | | `bots` | object | see below | Bots in empty seats. | | `pickups` | object or `false` | see below | Items on the map. `false` removes them all. | | `aimAssist` | `AimAssistConfig` or `false` | `{ coneDeg: 8, strength: 0.55 }` | Pulls your aim onto targets inside the cone. `false` turns it off. Fields in [Combat](https://docs.moonspell.app/engine/combat.md). | | `multiplayer` | `boolean` | `true` | Shows the invite button. Needs the game's backend. | | `theme` | `{ accent?: string }` | `'#6CF3FF'` | HUD accent and the game's `accent`. | | `sounds` | `ShooterSounds` | none | Music and effects, see [Sounds](https://docs.moonspell.app/modules/shooter.md#sounds). Without it the match is silent. | | `haptics` | `boolean` | `true` | Vibrations for what happens to you. The player's haptics setting also turns them off. | ### `rules` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `scoreToWin` | `number` | per mode | Points that win a round, range per mode (table above). Ignored in `gun-game`. | | `roundSeconds` | `number` | `300` | Round length, 60 to 1200. `0` means no clock. | | `roundsToWin` | `number` | `1` | Rounds to win the match, 1 to 5 (best of 2n minus 1). | | `countdownSeconds` | `number` | `3` | "3, 2, 1, GO" before each round, 0 to 10. Nobody moves or shoots. | | `respawnSeconds` | `number` | `3` | Seconds before the dead come back, 1 to 15. | | `friendlyFire` | `boolean` | `false` | Team-mates hurt each other. | | `ladder` | `ShooterWeapon[]` | pistol, smg, shotgun, rifle, rocket, sniper, sword | Gun game: the weapons from first to last, 2 to 12. Exported as `DEFAULT_LADDER`. | | `meleeDemotes` | `boolean` | `true` | Gun game: a melee kill knocks the victim down one rung. | | `flagReturnSeconds` | `number` | `15` | CTF: seconds a dropped flag waits before going home, 3 to 60. | | `flagAtHomeToScore` | `boolean` | `true` | CTF: you can only score while your own flag is home. | | `hillRadius` | `number` | `3.5` | KOTH: hill radius in metres, 2 to 8. | | `hillMoveSeconds` | `number` | `0` (never) | KOTH: the hill moves every this many seconds. Any value other than 0 is clamped to 15 to 120. | ### `map` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `preset` | `'hub' \| 'yard' \| 'depot'` | `'hub'` | `hub`: four pillars around an open centre, crates in the lanes. `yard`: an open courtyard cut by broken walls, long sight lines. `depot`: three lanes split by long walls, with crossings. | | `size` | `number` | `22` | Half-size of the square in metres, 14 to 40. Small is fast and frantic; 30 and up suits snipers. | | `seed` | `number` | `7` | Jitter for crate placement. | | `boxes` | `LevelBox[]` | `[]` | Extra cover, as level boxes (see [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md)). | | `theme` | `LevelTheme` | per preset | Level colours: `sky`, `floor`, `wall`, `block`, `platform`, `fog`. | Every preset is mirrored through the centre: red spawns and flag at negative Z, blue at positive Z, the first hill in the middle, items in mirrored pairs. The module's tests check that bots can walk to every spawn, base, hill and item on every preset and size. If you add `boxes`, keep that true: walls about 0.6 m thick, crates no taller than about 1.6 m, nothing on a base, the centre or an item spot. ### `player` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `color` | CSS colour | `'#7C5CFF'` | Your body colour. Team modes use the team colour. | | `loadout` | `ShooterWeapon[]` | `['rifle', 'shotgun']` | 1 to 3 weapons; SWAP cycles them. Ignored in gun game. | | `health` | `HealthConfig` | `{ max: 100, regenPerSecond: 10, regenDelay: 4 }` | Hit points, armour and regeneration. Fields in [Combat](https://docs.moonspell.app/engine/combat.md). | | `camera` | `'third' \| 'first'` | `'third'` | The starting camera. The camera button switches it. | | `character` | `CharacterConfig` | `{ runSpeed: 6.8, walkSpeed: 3.2, jumpHeight: 1.3 }` | Movement. Fields in [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md). | A `ShooterWeapon` is a preset name (`'pistol'`, `'rifle'`, `'smg'`, `'shotgun'`, `'sniper'`, `'blaster'`, `'rocket'`, `'grenade'`, `'sword'`) or a full `WeaponConfig`. Start a custom weapon from a preset: ```tsx import { WEAPON_PRESETS } from '@/engine'; import type { ShooterWeapon } from '@/engine/modules/shooter'; const paintGun: ShooterWeapon = { ...WEAPON_PRESETS.smg, name: 'Paint gun', color: '#FF5CF0', damage: 34, magazine: 20 }; ``` ### `teams`, `seats` and `bots` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `teams.names` | `[string, string]` | `['Red', 'Blue']` | The two sides' names. | | `teams.colors` | `[string, string]` | `['#FF5C7A', '#5CA8FF']` | The two sides' colours. | | `bots.fill` | `boolean` | `true` | Bots take every empty seat. A joining friend takes a bot's seat; a leaving one gets a bot back. | | `bots.difficulty` | `'easy' \| 'normal' \| 'hard'` | `'normal'` | Aim error, reaction time and bursts. | | `bots.weapons` | `ShooterWeapon[]` | `['rifle', 'shotgun', 'smg', 'blaster']` | Handed out to bots in turn, 1 to 8. | Bots play the mode. In CTF, half of each team attacks the enemy flag and the rest guards their own, chases a carrier or returns a dropped flag; a bot with the flag runs it home. In KOTH they go for the hill. They fight whatever they see on the way. ### `pickups` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `health` | `number` | `50` | Hit points per health pack, 0 to 200. `0` means no health packs. | | `armor` | `number` | `50` | Armour per pack, 0 to 200. `0` means none. | | `ammo` | `boolean` | `true` | Ammo crates that refill the magazine. | | `weapons` | `ShooterWeapon[]` | `['rocket', 'sniper']` | Weapons lying on the map, up to 4. You keep one until you die. Always empty in gun game. `[]` means none. | | `respawnSeconds` | `number` | `20` | Seconds before a taken item comes back, 5 to 90. | ## Multiplayer With the game's backend deployed (`GAME_BACKEND_URL` set) and `multiplayer` on, the invite button opens a room and shares it. Friends who join take a bot's seat in the same match. There is no backend code to write: the backend scaffold's rooms relay the messages. - **One host.** The player who has been in the room longest is the host. Every phone works this out the same way from the room's player list, so there is no vote. The host runs the whole match: bots, every hit, scores, flags, the hill, items and the clock. It sends a snapshot about 15 times a second (`SNAPSHOT_HZ`). - **Guests** move their own body on their own phone, so there is no input lag, and send their position, trigger and aim. The host fires their shots, so damage, kills and captures are decided in one place. Guests draw everyone else from the snapshots. - **The host leaves:** the next oldest player takes over from the last snapshot and the match carries on. - **Without a backend**, or before anyone joins, the phone is the host of a match with bots. Limits: up to 8 seats. Knockback does not push guests. The host is a phone, so the match pauses for everyone while the host's app is in the background. Inside the Moonspell app the invite shares the game's link with the room in it. Outside it there is no share link, so the invite shares the room code as text; the engine joins a room when the game is opened with a `room` parameter in its URL (`useLaunchRoomId`, see [Networking](https://docs.moonspell.app/engine/net.md)). Joining from outside the Moonspell app is untested. ### Backend needs - **Rooms:** the scaffold's room as shipped (it seats up to 8). - **Leaderboard:** a winner's kills are submitted to the board `<id>-kills`. Add it to the backend's `BOARDS`, for example `"flag-rush-kills": { order: "desc", min: 0, max: 500 }`. Without the entry, scores stay on the phone. See the [backend overview](https://docs.moonspell.app/backend/overview.md) and [Realtime](https://docs.moonspell.app/backend/realtime.md). ## Sounds Every key of `sounds` is optional and takes a `require`d file. A missing key is silent, and with no `sounds` at all the module opens no audio. They play on the engine's mixer: shots and explosions are placed in the world (louder near you, panned to their side), the rest play in your ears. The haptics in the last column fire whether or not the sound is set, unless `haptics` is `false`. | Key | Plays | Haptic | | --- | --- | --- | | `music` | the match theme, looping | | | `shot` | every shot: yours, and others' where they stand | | | `hit` | your shot lands | | | `hurt` | you take a hit | heavy | | `kill` | you take someone down | success | | `death` | you go down | error | | `explode` | a rocket or splash weapon going off (ducks the music) | heavy, when it is near you | | `reload` | you reload | | | `respawn` | you are back in | | | `pickup` | you take an item | select | | `roundStart` | a round's countdown starts | | | `go` | the countdown's GO | tap | | `roundEnd` | a round ends, or the match ends in a draw | success if your side took it, warning if another did | | `victory` | your side wins the match | success | | `defeat` | another side wins the match | error | | `levelUp` | gun game: your next weapon | success | | `flagTaken`, `flagDropped`, `flagReturned`, `flagCaptured` | CTF: every flag event | success when you take a flag, warning when yours is taken; on a capture, success for the scorers and warning for the others | | `hillTaken` | KOTH: your side holds the hill alone | tap | | `hillLost` | KOTH: another side holds it | | ```tsx sounds: { music: require('../../assets/audio/arena.m4a'), shot: require('../../assets/sfx/arena/shot.wav'), hit: require('../../assets/sfx/arena/hit.wav'), kill: require('../../assets/sfx/arena/kill.wav'), }, ``` In multiplayer, guests hear the same cues. The host sends no events, so a guest works them out from the snapshots: its own hits, kills, hurts and deaths, the phase, the flags, the hill, new tracers and explosions. ## What you can customise | What | How | | --- | --- | | Mode and pacing | `mode`, `rules`, `seats`. | | Map | `map.preset`, `size`, `seed`, `boxes`, `theme`. | | Weapons | `player.loadout`, `rules.ladder`, `bots.weapons`, `pickups.weapons`, presets or your own `WeaponConfig`. | | Difficulty | `bots.difficulty`, `aimAssist`, `player.health`, `pickups`. | | Colours | `player.color`, `teams.colors`, `map.theme`, `theme.accent`. | | Sounds and haptics | `sounds` (keys above), `haptics`. | | A rule no mode has | Copy `src/engine/modules/shooter/ShooterGame.tsx` into `src/game/`, fix its relative imports to `@/engine` paths, and add the rule next to the match. Or build on `examples/pulse-arena/` with the combat kit directly. | The match itself is plain TypeScript, usable without React: `ShooterMatch` (`match.ts`) has `step(dt)`, `trigger(...)`, `swap`, `addHuman`, `removeHuman`, `standings()`, `drainEvents()` (round start, go, round end, match end, flag, hill, level-up, pickup) and `drainCombatEvents()` (shots, hits, kills). `ShooterCues` (`cues.ts`) turns both into the sounds and haptics above: call `collect(match, events, combatEvents)` once a frame. Also exported: `resolveShooterConfig`, `buildShooterMap`, `buildPickups`, `applyPickup`, `stepPickups`, `encodeSnapshot`, `applySnapshot`, `ShooterNet`, `pickHost`, the scene pieces `CombatantMeshes` and `ObjectiveMeshes`, and the HUD pieces `ScoreStrip`, `AnnouncementBanner` and `ObjectiveLine`. ## Dev and testing `npm test` runs `shooter.test.ts`: config defaults, map reachability for every preset and size, every mode, rounds, items, sounds and haptics (host and guest), snapshots and the host handover. In development builds the HUD shows a telemetry line (`hud-debug`: mode, phase, round, clock, health, kills and deaths, shots and hits, weapon, scores, bot behaviours, net role) and an **AUTO** button (`btn-autopilot`) that hunts the nearest enemy, plays the flag or the hill and fires on lock. Other `testID`s: `hud-countdown`, `hud-score` (team modes), `hud-scoreboard` (free-for-all), `hud-objective`, `hud-announce`, `hud-eliminated`, `hud-room`, `btn-invite`, `btn-camera`, `btn-offline`, `btn-exit`, and `game-<id>` on the root view. ## Gotchas - In `gun-game`, `scoreToWin` and `pickups.weapons` are ignored: the ladder decides both. - `loadout` is cut to 3 weapons and `pickups.weapons` to 4. - `rules.hillMoveSeconds: 5` becomes 15. Only `0` turns moving off. - Team colours override `player.color` in team modes. - Large `boxes` can block bots' paths to a flag, hill or item. Keep the rules under `map` above. --- # Kingdom module Source: https://docs.moonspell.app/modules/kingdom > defineKingdomGame builds a story-led town builder with joystick sorties from one config, with buildings on timers, producers, crops, debris, a hero class, a squad, enemy camps, rewards and chapters of dialogue. `defineKingdomGame(config)` makes a story-led town builder. The player grows a village (buildings on timers, producers to collect from, crops to plant and harvest, debris to clear) and rides out on joystick sorties with a hero and a squad to clear enemy camps. A story of chapters runs through both: dialogue with portraits, choices, objectives the town answers by itself, rewards and unlocks. Use it for "a kingdom I build and defend", "a farm with a story" or "a village and raids". ```tsx import { defineKingdomGame } from '@/engine/modules/kingdom'; import type { KingdomConfig } from '@/engine/modules/kingdom'; ``` Source: `templates/crescent-game/src/engine/modules/kingdom/`. It uses the engine's [story kit](https://docs.moonspell.app/engine/story.md), the floating `Joystick` ([Input](https://docs.moonspell.app/engine/input.md)), TypeGPU grass ([GPU effects](https://docs.moonspell.app/engine/gpu-effects.md)), generated or packaged models ([Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md)) and [saves](https://docs.moonspell.app/engine/saves.md). ## The loop 1. The title screen, then chapter 1 starts: `title`, `say` and `choice` beats wait for a tap. 2. A `scene: 'sortie'` beat starts a fight: the hero and the squad walk a foggy field with the joystick, camps wake as they come near, and a Victory sheet grants the mission's rewards. 3. Back in the village, `objective` beats wait for the town's own counts (harvest, build, collect, clear, hold, upgrade, win a sortie). A met objective moves the story on by itself. 4. `reward` beats grant items, `scene: 'unlock'` opens new buildings or crops, and the next chapter raises the stakes. 5. After the last beat the village keeps running: timers, producers, crops and sorties stay available. ## The smallest complete game Every kingdom needs a theme accent, resources, buildings, units, hero classes, missions and a story. This is about as short as that gets: ```tsx // src/game/index.tsx import { defineKingdomGame } from '@/engine/modules/kingdom'; export default defineKingdomGame({ id: 'small-holding', title: 'Small Holding', description: 'Drive the raiders off, then grow wheat and build a farm.', theme: { accent: '#27B3AD' }, resources: [ { id: 'grain', name: 'Grain', icon: 'leaf.fill', start: 40 }, { id: 'wood', name: 'Wood', icon: 'tree.fill', start: 60 }, ], buildings: [ { id: 'hall', name: 'Town hall', shape: 'hall', footprint: [2, 2], cost: { wood: 200 }, buildSeconds: 60 }, { id: 'farm', name: 'Farm', shape: 'farm', cost: { wood: 25 }, buildSeconds: 12, produces: { resource: 'grain', amount: 10, seconds: 25 } }, ], crops: [{ id: 'wheat', name: 'Wheat', seconds: 20, yields: { resource: 'grain', amount: 15 } }], units: [{ id: 'raider', name: 'Raider', hp: 30, damage: 5, range: 1.4, cooldown: 1, speed: 3.9, color: '#B33A2E' }], classes: [{ id: 'knight', name: 'Knight', detail: 'Sword and shield.', hp: 150, damage: 18, range: 1.9, cooldown: 0.55, speed: 5.4, weapon: 'blade', color: '#2E4FA8' }], missions: [{ id: 'fields', name: 'The fields', camps: [{ x: 0, z: -8, units: ['raider', 'raider', 'raider'] }], rewards: { grain: 60, wood: 60 } }], town: { prebuilt: [{ building: 'hall', lot: [3, 1] }], plots: [[1, 6], [2, 6]], }, story: { speakers: [{ id: 'elder', name: 'Elder Bryn', role: 'Village elder' }], chapters: [ { id: 'raiders', title: 'Raiders', beats: [ { kind: 'title', text: 'Chapter I', sub: 'Raiders' }, { kind: 'say', speaker: 'elder', text: 'Raiders camp past the fields. Drag your thumb anywhere to move.' }, { kind: 'scene', scene: 'sortie', args: { mission: 'fields' } }, { kind: 'objective', id: 'sortie:fields', text: 'Drive off the raiders' }, { kind: 'objective', id: 'harvest:wheat', text: 'Harvest wheat', target: 15, hint: 'Tap a plot to plant' }, { kind: 'objective', id: 'build:farm', text: 'Build a farm', hint: 'Build, then tap a green lot' }, { kind: 'say', speaker: 'elder', text: 'The village will eat this winter.' }, ], }, ], }, }); ``` Until models and portraits are given, the module draws fallback shapes: a procedural building per `shape`, capsules with weapons for fighters, and SF Symbol icons for resources and portraits. `examples/oath-of-ashvale/index.tsx` is a complete game with two chapters, rigged characters, generated buildings, a sprite sheet of portraits and icons, and a full sound set. ## Controls - **Village:** tap a lot, a building, a plot or debris to open its panel; drag to pan; pinch to zoom. The build button opens the build sheet. - **Sortie:** the floating joystick appears under the thumb anywhere on the screen. The hero and the squad attack on their own. In a desktop browser, WASD or the arrows move. ## Config reference `resolveKingdomConfig` fills defaults, clamps ranges and drops references that point nowhere (an unknown resource in a cost, an unknown unit in a camp), with a warning in development. ### Top level | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | URL-safe id. Also keys the save slot `kingdom:<id>`. | | `title` | `string` | required | Shown on the title screen. | | `description` | `string` | required | Shown on the title screen and the game's card. | | `badge` | `string` | none | Card metadata. | | `theme` | `KingdomTheme` | see below | Colours, sky and fonts. `accent` is required. | | `resources` | `ResourceDef[]` | required | The currencies. | | `buildings` | `BuildingDef[]` | required | What can be built. | | `crops` | `CropDef[]` | `[]` | What can be planted on plots. | | `units` | `UnitDef[]` | required | Fighters: allies and enemies. | | `classes` | `HeroClassDef[]` | required (may be empty) | The hero's options. An empty list gives a default sword hero. | | `missions` | `MissionDef[]` | required | The sorties. | | `town` | `TownDef` | see below | Village size and layout. | | `story` | `Story` | required | Speakers and chapters. | | `art` | `{ sheet?: { source, cols, rows } }` | none | A sprite sheet of cut-outs (portraits, resource icons, crops). Cells are picked by `frame`. | | `sounds` | `KingdomSounds` | none | Music and effects. Without it the game is silent. | | `board` | `string` | `'kingdom-power'` | Leaderboard name for the kingdom's power. | | `saveVersion` | `number` | `1` | The save slot's version. Bump it with a migration when an existing save's shape changes. | | `migrations` | `Record<number, (old) => unknown>` | none | `migrations[n]` turns a version n-1 save into version n, passed to the save slot as is. See [Saves and backend](https://docs.moonspell.app/modules/kingdom.md#saves-and-backend). | | `objectives` | `Record<string, (town: TownState) => number>` | none | The game's own counts for objective ids the module doesn't count, see [Story](https://docs.moonspell.app/modules/kingdom.md#story). | ### `theme` | Field | Default | Meaning | | --- | --- | --- | | `accent` | required | Primary buttons, the quest badge, the hero ring. | | `onAccent` | `'#FFFFFF'` | Text on accent buttons. | | `panel` | `'#FFF8EE'` | Dialogue box, sheets and panels. | | `text` | `'#2A2430'` | Text. | | `dim` | `'#7A7189'` | Secondary text. | | `ground` | `'#6FA84F'` | Field colour. | | `grass` | `'#7FBA5A'` | The village's grass. | | `path` | `'#C9B48A'` | Paths between lots. | | `water` | `'#4FA3D9'` | The water row. | | `sky` | `'day'` | An environment preset: `'day'`, `'sunset'`, `'night'`, `'overcast'`, `'neon'` or `'space'`. | | `wall` | `'#E6D8BF'` | Fallback building walls. | | `roof` | `'#8A4A2E'` | Fallback building roofs. | | `trim` | `'#4A3A2E'` | Fallback building trim. | | `danger` | `'#FF3B3B'` | Enemy camp rings. | | `banner` | `'#E2A63A'` | Victory and chapter ribbons. | | `fog` | `'#DCE9DF'` | Sortie fog and the canvas background. | | `fonts` | Nunito black and bold | `{ display, ui? }` from `@/engine/ui/fonts`. `ui` defaults to `display` when only `display` is given. | ### `resources` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | Referenced by costs, yields and rewards. | | `name` | `string` | required | Display name. | | `icon` | `IconName` | required | SF Symbol name, for example `'leaf.fill'`. | | `frame` | `number` | none | A cell of `art.sheet`, shown instead of the icon. | | `color` | CSS colour | `theme.accent` | Its colour in the HUD. | | `start` | `number` | `0` | What a new village holds. | | `cap` | `number` | `0` (no cap) | Storage cap. | ### `buildings` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | Referenced by `build:` objectives, `prebuilt` and `unlock` scenes. | | `name` | `string` | required | Display name. | | `shape` | `'hall' \| 'house' \| 'farm' \| 'tower' \| 'workshop' \| 'camp' \| 'shrine' \| 'wall' \| 'well'` | required | The fallback shape drawn until a `model` is given. | | `description` | `string` | `''` | One line on the build card. | | `icon` | `IconName` | none | Build card icon. | | `frame` | `number` | none | A cell of `art.sheet` for the build card. | | `footprint` | `[number, number]` | `[1, 1]` | Lots wide and deep, 1 to 3 each. | | `cost` | `Costs` | required | Level 1 price, per resource id. | | `buildSeconds` | `number` | required | Level 1 build time, 0 (instant) to 6 hours. | | `maxLevel` | `number` | `3` | Highest level, 1 to 10. | | `upgradeScale` | `number` | `1.8` | Level n costs `cost` times `upgradeScale` to the power n minus 1, and takes as long. 1 to 4. `costAt(building, level)` computes it. | | `produces` | `{ resource, amount, seconds }` | none | Makes `amount` times level of `resource` every `seconds` (3 s to a day), collected with a tap. | | `power` | `number` | `10` | Kingdom power per level, the leaderboard score. | | `troops` | `{ unit, count }` | none | `count` times level of that unit join the hero on every sortie (a barracks). | | `colors` | `{ wall?, roof?, trim? }` | the theme's | Fallback shape colours. | | `model` | `number` (a `require`d `.glb`) | none | A building model, placed in its lot at its own proportions. | | `unlocked` | `boolean` | `true` | `false` hides it until a `scene: 'unlock'` beat opens it. | ### `crops` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | Referenced by `plant:` and `harvest:` objectives. | | `name` | `string` | required | Display name. | | `seconds` | `number` | required | Planting to harvest, 3 s to a day. | | `yields` | `{ resource, amount }` | required | What a harvest gives. A crop yielding an unknown resource is dropped. | | `cost` | `Costs` | free | Seed price. | | `icon`, `frame` | `IconName`, `number` | none | Icon or sheet cell. | | `stages` | `number[]` | none | Sheet cells shown as the crop grows, sprout to ripe. Without them the module draws growing stalks. | | `color` | CSS colour | `'#9BD35A'` | Fallback crop colour. | | `unlocked` | `boolean` | `true` | `false` hides it until a `scene: 'unlock'` beat opens it. | ### `units` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | Referenced by camps, `escort` and `troops`. | | `name` | `string` | required | Display name. | | `hp` | `number` | required | Hit points, at least 1. | | `damage` | `number` | required | Damage per hit. | | `range` | `number` | required | Attack reach in metres, 0.8 to 12. Melee about 1.4, spear 2, bow 6. | | `cooldown` | `number` | required | Seconds between attacks, 0.2 to 10. | | `speed` | `number` | required | Metres per second, 0.5 to 12. | | `color` | CSS colour | required | Fallback capsule colour. | | `height` | `number` | `1.4` | Height in metres of the capsule and the model. 1.9 to 2.2 reads as a brute. | | `weapon` | `'blade' \| 'spear' \| 'bow'` | `'blade'` | Fallback weapon and attack style. | | `model` | `number` | none | A rigged character `.glb`. | | `clips` | `Record<string, number>` | none | Extra animation files by name. A file with several clips exposes them as `name.Clip`, for example `move.Running_A`. | | `animations` | `{ idle?, run?, attack?, death? }` | `idle` and `run` | Which clip plays when. | | `tint` | CSS colour | none | Multiplied into the model's colours, so one model serves several teams. | | `held` | `{ right?, left?, rightBone?, leftBone?, scale? }` | none | Prop models attached to the rig's hand bones: a sword, a shield, a bow. | ### `classes` The hero's options. Same fighting fields as a unit (`hp`, `damage`, `range`, `cooldown`, `speed` from 1 to 12, `weapon`, `color`, `model`, `clips`, `animations`, `tint`, `held`; `weapon` is required here), plus: | Field | Type | Meaning | | --- | --- | --- | | `id` | `string` | Must match an option id of the story's `choice` with `id: 'class'`. | | `name` | `string` | Display name. | | `detail` | `string` | One line on the choice card. | | `icon`, `portrait` | `IconName`, `Portrait` | Shown on the choice card. | The first class is used until the player picks one. The pick is kept in the save. ### `missions` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `id` | `string` | required | Referenced by `scene: 'sortie'` and `sortie:` objectives. | | `name` | `string` | required | Display name. | | `size` | `number` | `30` | Half-size of the field in metres, 12 to 80. | | `heroSpawn` | `{ x, z }` | `{ x: 0, z: size * 0.7 }` | Where the hero starts. | | `camps` | `CampDef[]` | required | Enemy camps, see below. | | `rewards` | `Costs` | required | Granted on victory. | | `ground` | CSS colour | `theme.ground` | Field colour. | | `fog` | `boolean` | `true` | Fog on the field. | | `escort` | `string[]` | `[]` | Ally unit ids that follow the hero, on top of the barracks' troops. | A camp is `{ x, z, units, radius?, banner? }`: a red ring at `(x, z)` with these unit ids in it. `radius` defaults to 3.5 (1.5 to 8). A camp sleeps until the hero or an ally comes within `radius + 2.5` m. `banner` is a label over the ring. ### `town` | Field | Type | Default | Meaning | | --- | --- | --- | --- | | `size` | `[cols, rows]` | `[8, 10]` | Lots across and down, 5 to 14 each. Columns grow to the right of the screen, rows go down it. | | `lot` | `number` | `4` | Metres per lot, 2.5 to 8. | | `prebuilt` | `{ building, lot, level? }[]` | `[]` | Buildings standing at the start. Entries naming an unknown building are dropped. | | `plots` | `[col, row][]` | `[]` | Lots that are crop plots. | | `debris` | `{ kind, lot, seconds?, yields? }[]` | `[]` | Trees, rocks, rubble or brush to clear. `seconds` defaults to 5. | | `paths` | `[col, row][][]` | `[]` | Paths drawn on the ground, as lot-to-lot polylines. | | `water` | `{ row, width? }` | none | A river band across the village at that row, `width` rows wide (default 1). | | `villagers` | `VillagerDef[]` | `[]` | Rigged models that walk a path back and forth: `{ model, clips?, walk?, idle?, tint?, height?, path?, speed? }`, walking at 1.2 m/s by default. | ### `story` `speakers` (`{ id, name, role?, color?, portrait?, side? }`) and `chapters` (`{ id, title, beats }`), the engine's [story kit](https://docs.moonspell.app/engine/story.md) types. A `portrait` is a sheet cell (`{ source, cols, rows, frame }`) or `{ icon }`. | Beat | Fields | Waits for | | --- | --- | --- | | `title` | `text`, `sub?` | a tap | | `say` | `speaker`, `text` | a tap (the first tap finishes the typing). Skip jumps to the next choice, objective or scene. | | `choice` | `id`, `title`, `options: { id, label, detail, icon?, portrait? }[]`, `confirm?` | the confirm button. `id: 'class'` sets the hero class. | | `objective` | `id`, `text`, `target?` (default 1), `hint?` | the town's counts (below), or the game's own count in `objectives`, reaching `target` | | `reward` | `title?`, `items: { resource, amount }[]` | the button; the items are granted then | | `scene` | `scene`, `args?` | nothing, see below | The `scene` beats the module performs: | `scene` | `args` | Does | | --- | --- | --- | | `sortie` | `mission` | starts that mission (default: the first) | | `unlock` | `building` or `crop` | unlocks it and shows a toast | | `focus` | `col`, `row` | pans the village camera to that lot | | `town` | none | returns to the village | Objective ids the module counts by itself: | Id | Counts | | --- | --- | | `build:<building>` | buildings of that kind standing | | `upgrade:<building>:<n>` | 1 once one reaches level n | | `plant:<crop>` | plantings | | `harvest:<crop>` | units harvested | | `collect:<resource>` | units collected from producers | | `hold:<resource>` | the current amount in the wallet | | `clear` | debris cleared | | `sortie:<mission>` | sorties of that mission won | | `class` | 1 once a hero class was picked | Any other id needs the game's own count in `objectives`: a function of the village (`TownState`: `wallet`, `buildings`, `plots`, `stats`, `heroClass` and more) that returns the objective's progress. It is read twice a second like the built-in ones and compared with `target`. Without one, the objective never completes. ```tsx objectives: { feast: (town) => (town.wallet.wheat >= 300 && town.buildings.some((b) => b.kind === 'hall' && b.level >= 2) ? 1 : 0), 'big-village': (town) => town.buildings.filter((b) => b.level >= 1).length, // with target: 8 }, ``` The function returns a total, not an increment. A count that throws or returns something other than a number is skipped. A count for a built-in id replaces the module's. ## Sounds Every key of `sounds` is optional and takes a `require`d file: | Key | Plays | | --- | --- | | `music` | the village theme, looping | | `battle` | the sortie theme, crossfading in on every sortie | | `tap` | taps on dialogue and buttons | | `build` | a build or upgrade starting or finishing | | `harvest` | a harvest | | `collect` | collecting from a producer or a reward | | `hit` | a hit landing | | `bow` | a ranged swing (falls back to `hit`) | | `whoosh` | a melee swing (falls back to `hit`) | | `step` | the hero's footsteps on a sortie | | `grunt` | the hero taking a hit | | `groan` | an enemy going down | | `horn` | a camp waking | | `victory` | a sortie won | | `defeat` | a sortie lost | ## What you can customise | What | How | | --- | --- | | Economy and pacing | `resources`, building `cost`, `buildSeconds`, `upgradeScale`, `produces`, crop `seconds` and `yields`, mission `rewards`. Make every chapter's objectives affordable from what the chapter gives. | | Fights | `units`, `classes`, camps per mission, `escort`, `troops`. | | The village | `town` size, prebuilt buildings, plots, debris, paths, water, villagers. | | The story | `story.speakers` and `story.chapters`, plus `objectives` for objective ids of your own. Keep lines under about 140 characters; the box types them out. | | Look | `theme`, building `colors`, `model` on buildings and units, `art.sheet` and `frame` for icons and portraits. | | Sound | `sounds`. | | Rules | The town rules (`town.ts`) and the sortie simulation (`sortie.ts`) are pure TypeScript with tests, exported for tools and tests: `createTown`, `place`, `upgrade`, `tick`, `collect`, `plant`, `harvest`, `startClear`, `power`, `troopsOf`, `createSortie`, `stepSortie`, `objectiveValue`, `autoObjectives` and more. The scene pieces (`BuildingShapeMesh`, `FighterMesh`, `CampRing`, `PlotMesh`, `DebrisMesh`, `ReadyMarker`) are exported too. | The kingdom's building blocks also work in any game on their own: the `Joystick`, the story kit's `DialogueBox`, `ChoiceCards` and `RewardSheet`, `Grass` and `LivelyGround`, `Water`, `SheetImage` and `Billboard`. See [Input](https://docs.moonspell.app/engine/input.md), [Story](https://docs.moonspell.app/engine/story.md), [GPU effects](https://docs.moonspell.app/engine/gpu-effects.md) and [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md). ## Saves and backend The village, wallet, timers, story position and hero class live in one save slot, `kingdom:<id>`. Timers are absolute timestamps, so time passes while the app is closed. With a backend the slot syncs to the player's other devices ([Saves sync](https://docs.moonspell.app/backend/saves-sync.md)). Fields the module adds later reach old saves by themselves. When a game changes the shape of what an existing village keeps, bump `saveVersion` and add a migration: `migrations[n]` gets a version n-1 save (`{ town, story, startedAt, sessions }`) and returns version n. They run in order, each on a copy ([Saves](https://docs.moonspell.app/engine/saves.md)). `kingdomSaveSpec(config)` returns the slot's spec, for testing a migration with `defineSave` and `memoryStore()`. ```tsx saveVersion: 2, migrations: { 2: (old) => ({ ...old, town: old.town && { ...old.town, wallet: { ...old.town.wallet, stone: 0 } } }) }, ``` With `GAME_BACKEND_URL` set, the kingdom's power is posted to the `board` leaderboard (default `kingdom-power`), and the title shows how many are playing. Power is the sum of each building's `power` times its level, plus 2 per debris cleared and 25 per sortie won. The backend scaffold's `BOARDS` does not list `kingdom-power`: add it, for example `"kingdom-power": { order: "desc", min: 0, max: 1_000_000 }`, or the score stays on the phone. See the [backend overview](https://docs.moonspell.app/backend/overview.md). ## Dev and testing `npm test` runs `kingdom.test.ts`: config defaults, placing, building, upgrading, crops, debris, power, objectives (built-in and the game's own), the save slot and its migrations, and the sortie simulation. In development builds the HUD shows a telemetry line (`hud-debug`) and an **AUTO** button (`btn-autopilot`). In the village AUTO answers dialogue, picks the first class, plants, harvests, collects, builds what the objective asks and clears debris; on a sortie it walks the hero into the nearest camp. Other `testID`s include `title-screen`, `btn-play`, `chapter-title`, `quest-tracker`, `resource-bar`, `res-<id>`, `btn-build`, `build-sheet`, `build-<id>`, `building-panel`, `btn-upgrade`, `btn-collect`, `plot-panel`, `btn-plant-<id>`, `btn-harvest`, `debris-panel`, `btn-clear`, `btn-sortie`, `hud-enemies`, `hud-hp`, `victory-sheet`, `defeat-sheet`, `btn-retry`, `btn-pause`, `pause-sheet` and `hud-power`. ## Gotchas - The story is required, and it is the only guide the player gets. A chapter with no objective or sortie leaves the player in the village with nothing to do but tap. - The hero class choice must have `id: 'class'` and option ids that match `classes` ids, or the pick is ignored. - Objectives read the town's running totals, so work done before the objective appears already counts. A `hold:` objective reads the wallet, so spending can undo it. - Costs, yields and rewards that name an unknown resource are dropped silently in production. - Bumping `saveVersion` without a migration for the new version only fills in missing fields; anything renamed or reshaped needs `migrations`. Adding a field to the save needs neither (see [Saves](https://docs.moonspell.app/engine/saves.md)). - Not in the module: alliances, collectible heroes, player-versus-player, a shared world map and tower placement. --- # Writing a module Source: https://docs.moonspell.app/modules/writing-a-module > How to add a new game module to the engine, following the file layout, define function, config types, tests, docs, skill and example that the four existing modules share. Write a module when one genre should be quick and reliable for every new game: a config in, a complete game out. If only one game needs the genre, write that game from the engine pieces instead (see [Game modules](https://docs.moonspell.app/modules/overview.md#when-no-module-fits)). A module is the same code moved into `src/engine/modules/<name>/` with its knobs pulled out into a config. This page lists the conventions the racing, puzzle, shooter and kingdom modules share. Follow them so the module reads like the others to people and to coding agents. ## File layout Everything lives in `templates/crescent-game/src/engine/modules/<name>/`: | File | Holds | In the existing modules | | --- | --- | --- | | `index.ts` | the public exports, and nothing else | every module | | `types.ts` | the `Config` the game writes and the `ResolvedConfig` with every default filled in | `racing/types.ts`, `shooter/types.ts`, `kingdom/types.ts` (puzzle keeps them in `config.ts`) | | `config.ts` | `resolve<Name>Config(config)`: fills defaults, clamps ranges, drops bad references | `resolveRaceConfig`, `resolvePuzzleConfig`, `resolveShooterConfig`, `resolveKingdomConfig` | | pure rule files | the game's rules as plain TypeScript, no React, no React Native | `raceState.ts`, `vehicle.ts`, `track.ts`; `packs/`, `session.ts`, `goals.ts`; `match.ts`, `maps.ts`, `pickups.ts`, `snapshot.ts`; `town.ts`, `sortie.ts`, `objectives.ts` | | `<Name>Game.tsx` | the screen and `define<Name>Game` | `RaceGame.tsx`, `PuzzleGame.tsx`, `ShooterGame.tsx`, `KingdomGame.tsx` | | `<Name>Hud.tsx`, screens | the HUD and the title, pause and results screens | `RaceHud.tsx`, `PuzzleScreens.tsx`, `ShooterHud.tsx`, `KingdomHud.tsx` | | meshes or render | the scene pieces | `TrackMeshes.tsx`, `Car.tsx`; `render.ts`; `ShooterMeshes.tsx`; `meshes.tsx`, `TownScene.tsx`, `SortieScene.tsx` | | `*.test.ts` | unit tests for the pure files | `puzzle/levels.test.ts`, `puzzle/packs.test.ts`, `shooter/shooter.test.ts`, `kingdom/kingdom.test.ts` | Inside the module, import engine pieces by relative path (`../../render/EngineCanvas`, `../../input/TouchControls`). Games import the module by its own path, `@/engine/modules/<name>`; modules are not re-exported from `@/engine`. ## The define function One entry point, named `define<Name>Game`, takes the config and returns a `GameDefinition`. Both types come from `src/engine/core/types.ts` (inside a module, `import type { GameDefinition, GameScreenProps } from '../../core/types'`; games get them from `@/engine`). The shape every module uses, shown here with the shooter's: ```tsx export const defineShooterGame = (config: ShooterConfig): GameDefinition => { const resolved = resolveShooterConfig(config); const Component = (props: GameScreenProps) => <ShooterGame config={resolved} {...props} />; Component.displayName = 'ShooterGameScreen'; return { id: resolved.id, title: resolved.title, description: resolved.description, badge: resolved.badge, accent: resolved.accent, Component, }; }; ``` - Resolve the config once, outside the component. - `Component` receives `onExit` from `GameScreenProps`. Every exit control in the module calls it. - Pick a sensible `accent` from the config (the racing module uses the car's accent, the puzzle module `theme.accent`). - Keep anything that must exist once per game (a save slot definition, as the kingdom module does with `defineSave`) next to the resolve call, not inside the component. The game then writes one file: ```tsx // src/game/index.tsx import { defineShooterGame } from '@/engine/modules/shooter'; export default defineShooterGame({ id: 'yard-brawl', title: 'Yard Brawl', description: 'Everyone against everyone.' }); ``` ## Config types - Only identity fields are required (`id`, `title`, `description`) where the genre allows. The kingdom module also requires its content tables, because a kingdom with no buildings or story is not a game. - Put the range and the default in each field's doc comment, for example `/** Road width in metres. 8-16. Default 11. */`. Agents read these comments. - Use string unions for choices (`'ffa' | 'tdm' | ...`) and export the list when it is useful at runtime (`SHOOTER_MODES`, `SHOOTER_MAPS`). - Group fields by topic (`track`, `car`, `opponents`, `theme`; `rules`, `map`, `player`, `bots`, `pickups`) rather than one flat object. - The resolver clamps every number into a range that plays well and falls back on unknown values, so a config written in a hurry still runs. Drop references that point nowhere (the kingdom resolver warns about them in development) instead of crashing. - Assets come in as `require(...)` numbers on optional fields (`model`, `sounds`, `art.sheet`). The module always has a fallback: a procedural shape, placeholder sounds or silence, an icon instead of a sprite. ## The screen - A 3D module renders an `EngineCanvas` inside an `EngineErrorBoundary`, runs its simulation in `useFrame`, and writes per-frame numbers to Reanimated shared values so React does not re-render every frame. A 2D module renders `GameCanvas2D`. See [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md) and [2D layer](https://docs.moonspell.app/engine/2d.md). - Use the engine's controls (`TouchControls`, `CharacterControls`, `Joystick`). They already answer to the keyboard in a desktop browser. See [Input](https://docs.moonspell.app/engine/input.md). - Leaderboards, presence and rooms go through the [net hooks](https://docs.moonspell.app/engine/net.md) with `GAME_BACKEND_URL`, so the module works with and without a backend. Progress goes through [saves](https://docs.moonspell.app/engine/saves.md). - Nothing platform-specific in the module. What differs between web, iOS and Android belongs in the engine's platform layer (`*.web.ts` files, `Platform.OS`). ## Dev and test hooks Every module has the same three, so automation can drive any of them: - A telemetry line with `testID` `hud-debug`, shown only when `__DEV__` is true. - An **AUTO** button (`btn-autopilot`, or `btn-autoplay` in the puzzle module) that plays the game by itself, also only in development. - A `testID` and an `accessibilityLabel` on every control, and `game-<id>` on the root view. ## Tests Put the rules in pure files and test them with Node's test runner next to the code, as `*.test.ts`: ```ts import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { resolveKingdomConfig } from './config'; ``` `npm test` in the template runs every `src/engine/**/*.test.ts`. Test at least: the resolver's defaults and clamps, each rule, the end-of-round conditions, and that the AUTO player reaches the end. The shooter's tests also walk every map preset to check that bots can reach every objective. ## Docs, skill and example A module is not done until people and agents can find it and use it: | Piece | Where | What | | --- | --- | --- | | Agent reference | `templates/crescent-game/docs/<name>-module.md` | every config field with type, range and default, a copy-ready config, backend needs | | Skill | `templates/crescent-game/.claude/skills/<genre>-game/SKILL.md` | how to turn a player's words into a config: tables of "player says" to fields, naming, difficulty, a checklist | | Router row | `templates/crescent-game/.claude/skills/game-router/SKILL.md` | the trigger words, the route, what to start from, and what the module cannot do | | Agent instructions | the template's `CLAUDE.md` and `AGENTS.md` ("What you can build") and `docs/engine-api.md` (the module list) | one entry pointing at the doc, the skill and the example | | Example | `templates/crescent-game/examples/<game>/index.tsx` | a complete, self-contained game on the module that a game can copy over `src/game/index.tsx` | | Public page | `docs/modules/<name>.md` on this site | the page format the other module pages use | If the module uses the backend, document which leaderboard names it submits to (and add them, or a `"prefix-*"` family, to the scaffold's `BOARDS` if every game of the module uses them), whether it needs rooms, and what it saves. See the [backend overview](https://docs.moonspell.app/backend/overview.md). ## Checklist 1. `src/engine/modules/<name>/` with `index.ts`, the config types, `config.ts`, the pure rules, the screen and `define<Name>Game`. 2. Fallbacks for every asset field. 3. `hud-debug`, AUTO, `testID`s, an exit that calls `onExit`. 4. Tests for the pure parts; `npm test` and `npm run typecheck` pass. 5. The game runs on the web (`npm run web`) and on a phone build. 6. Agent doc, skill, router row, `CLAUDE.md` / `AGENTS.md` / `engine-api.md` entries, an example, a public page. 7. Backend entries if the module uses leaderboards or rooms. For the Moonspell app, the hosted builder (Nova) also has a list of what it can build in `agent/instructions.ts`; add the module there too if you contribute to the app. How the app picks up engine changes is on the [Moonspell pages](https://docs.moonspell.app/moonspell/architecture.md). --- # Example games Source: https://docs.moonspell.app/examples > The eleven example games in templates/crescent-game/examples, what each one shows, which engine pieces or module it uses, which files to read first, and how to run one. The template ships eleven complete games in `templates/crescent-game/examples/`. Each is one self-contained `index.tsx` that default-exports a `GameDefinition`, so any of them can replace the game in `src/game/index.tsx`. Use them two ways: to see how an engine piece or module is used in a real game, and as the starting point for your own game of that kind. The twelfth game is the starter, **Sunset Sprint**, in `src/game/index.tsx`: a [racing module](https://docs.moonspell.app/modules/racing.md) config. ## At a glance | Example | Kind of game | Built on | 2D or 3D | | --- | --- | --- | --- | | [moon-blocks](https://docs.moonspell.app/examples.md#moon-blocks) | block puzzle, endless | [puzzle module](https://docs.moonspell.app/modules/puzzle.md), `block` pack | 2D | | [moonrise](https://docs.moonspell.app/examples.md#moonrise) | merge puzzle with levels | puzzle module, `merge` pack, drawn tiles | 2D | | [gem-cascade](https://docs.moonspell.app/examples.md#gem-cascade) | match-three with levels | puzzle module, `match` pack in swap mode | 2D | | [flag-rush](https://docs.moonspell.app/examples.md#flag-rush) | 3 v 3 capture the flag | [shooter module](https://docs.moonspell.app/modules/shooter.md) | 3D | | [oath-of-ashvale](https://docs.moonspell.app/examples.md#oath-of-ashvale) | story-led town builder with sorties | [kingdom module](https://docs.moonspell.app/modules/kingdom.md) | 3D | | [rooftop-hop](https://docs.moonspell.app/examples.md#rooftop-hop) | parkour course over a void | [character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md) | 3D | | [lantern-tag](https://docs.moonspell.app/examples.md#lantern-tag) | collect lanterns, avoid guards | [bots](https://docs.moonspell.app/engine/bots.md) | 3D | | [pulse-arena](https://docs.moonspell.app/examples.md#pulse-arena) | free-for-all against armed bots | [combat](https://docs.moonspell.app/engine/combat.md) | 3D | | [tap-pop](https://docs.moonspell.app/examples.md#tap-pop) | one-tap arcade, 30 second rounds | [2D layer](https://docs.moonspell.app/engine/2d.md) | 2D | | [sky-runner](https://docs.moonspell.app/examples.md#sky-runner) | side-scrolling platformer | 2D layer platformer kit | 2D | | [moon-chess](https://docs.moonspell.app/examples.md#moon-chess) | chess against talking rivals or a friend | [turns](https://docs.moonspell.app/engine/turns.md) | 2D | The five module examples are configs: change fields, not code. The six others write their own loop on the engine pieces, so everything (level, rules, camera, HUD) is in that one file. ## How to run an example An example runs by becoming the game. From `templates/crescent-game`: ```bash cp examples/lantern-tag/index.tsx src/game/index.tsx npm run typecheck ``` Then start the game the way the Quickstart describes, for example in a browser with `npm run web`. To go back to the starter game, restore the file with `git checkout src/game/index.tsx`. Run instructions: see [Quickstart](https://docs.moonspell.app/start/quickstart.md). The relative `require('../../assets/...')` paths in the examples resolve the same from `examples/<name>/` and from `src/game/`, so the copy needs no edits. Every example is checked by `npm run typecheck`, because the template's TypeScript config includes `examples/`. All examples keep the same dev conventions: in development builds they show a `hud-debug` telemetry line and an **AUTO** button that plays the game by itself, every control has a `testID`, and the exit control calls `onExit`. Production builds hide the dev pieces. Examples with a leaderboard or rooms work without a backend: scores stay on the device and rooms hold only you. To put them online, set `GAME_BACKEND_URL` and deploy the backend scaffold ([backend overview](https://docs.moonspell.app/backend/overview.md)); each leaderboard name an example uses must be in the backend's `BOARDS`. ## Module examples ### moon-blocks **Moon Blocks**: the puzzle module's starter. The `block` pack, endless, on a night-sky palette. Drag pieces from the tray onto the 8 x 8 board; full rows and columns clear; the round ends when no piece fits. - Shows: the smallest real puzzle config: `pack`, `mode`, `board`, `block.pieces`, `block.tray`, `theme` colours and `tileStyle`. - Read first: `examples/moon-blocks/index.tsx` (30 lines), then [Puzzle module](https://docs.moonspell.app/modules/puzzle.md). - Backend: the module's `endless` board and presence, already in the scaffold's `BOARDS`. ### moonrise **Moonrise**: a merge puzzle in the night sky. Swipe to slide; two equal bodies become the next one, from a new moon through full moons, planets, a sun and a galaxy to the last step. Levels ask for the next body each time. - Shows: the `merge` pack in `levels` mode, `theme.names` naming every step, every tile drawn in code with `theme.paintTile`, a twinkling starfield with `theme.paintBackground`, and a font family picked with `fonts`. - Read first: `examples/moonrise/index.tsx`: the `paintTile` switch is the part to study for drawing tiles with the 2D layer's `Draw2D`. - Backend: the module's `levels` board. ### gem-cascade **Gem Cascade**: a match-three. Drag a gem onto a neighbour to line up three or more; lines shatter, gems fall, and new lines clear on their own as cascades. Levels ask for a number of gems in a few moves. - Shows: the `match` pack with `input: 'swap'`, levels, six gem cuts drawn with `paintTile` (one shape per colour, so colours also read by shape), `draw.clip` for facets, drifting sparkles in `paintBackground`, a custom `hint`. - Read first: `examples/gem-cascade/index.tsx`. ### flag-rush **Flag Rush**: three against three capture the flag on the `depot` map, first to three captures. Bots fill every empty seat; friends who join take a bot's place in the same match. - Shows: a full shooter config: `mode: 'ctf'`, `rules`, `map`, `player` loadout and camera, team names and colours, `seats`, bot difficulty and weapons, `pickups`. - Read first: `examples/flag-rush/index.tsx` (27 lines), then [Shooter module](https://docs.moonspell.app/modules/shooter.md). Change `mode` for team deathmatch, gun game, king of the hill or free-for-all. - Backend: rooms from the scaffold as shipped; add `flag-rush-kills` to `BOARDS` for the kills leaderboard. ### oath-of-ashvale **Oath of Ashvale**: a storybook realm with a queen, a marshal and a farmer. Two chapters of dialogue with cut-out portraits, a knight-or-ranger choice, joystick sorties against bandit camps in the fog, Victory rewards, wheat and carrots, a watchtower, a barracks and land to clear. - Shows: every table of the kingdom config filled in: resources with sheet icons, eight buildings with generated `.glb` models, crops, units and classes on rigged character models with shared animation files, `held` weapons, `tint` per team, two missions, a town with prebuilt buildings, plots, debris, paths, a water row and walking villagers, a story of two chapters (16 and 13 beats), a full `sounds` set and the `kingdom-power` board. - Read first: `examples/oath-of-ashvale/index.tsx`: the `story` block shows the beat order that works (title, say, class choice, sortie, objectives, reward, unlock). - Assets: the characters and weapons are the KayKit Adventurers pack (by Kay Lousberg, CC0) in `assets/models/kaykit/`; the buildings in `assets/models/ashvale/`; the portrait and icon sheet in `assets/sprites/oath-of-ashvale/sheet.png`; music and effects in `assets/audio/ashvale/` and `assets/sfx/ashvale/`. - Backend: add `kingdom-power` to `BOARDS` for the leaderboard; the save syncs through `PlayerSaves`. ## Engine-piece examples ### rooftop-hop **Rooftop Hop**: a parkour course over a void. Move with the left stick, drag the right half to look, JUMP across gaps. Rings are checkpoints, the green pad is the goal, falling returns you to the last checkpoint. The camera button cycles third person, first person, top-down and side view. - Shows: the character controller (`createCharacterState`, `stepCharacter`, `resolveCharacterParams`), the four camera rigs (`resolveCameraRig`, `CharacterCamera`, `nextCameraMode`), `CharacterControls`, a level from `resolveLevel` drawn with `LevelMeshes`, a moving platform on a path (`createPath`, `MovingPlatform`), checkpoints and the goal as zones (`useZones`), game feel (`GameFeel`, `juice`, `stretch`), a waypoint autopilot (`createWalker`, `waypointCommand`) and a fastest-time leaderboard. - Read first: `examples/rooftop-hop/index.tsx`, then [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md). - Backend: board `rooftop-hop`, lowest first. ### lantern-tag **Lantern Tag**: collect five lanterns in an arena while three guards patrol. They hear you sprint and land, see you when you cross their view, chase you and lose you again. Three lives. - Shows: bots (`BotSquad`, `BotMeshes`, `createRoster`, patrol routes, hearing and sight, `backOff`), pathfinding on a grid (`gridMap`, `findPath`, `nearestWalkable`), a hut and ramps laid out from a text map, a slow title orbit (`CameraDolly` on a closed path), typed signals between systems (`createSignals`, `useSignal`), positional sound (`useAudioMixer`, `AudioListener`), particles and a leaderboard. - Read first: `examples/lantern-tag/index.tsx`, then [Bots](https://docs.moonspell.app/engine/bots.md) and [Audio](https://docs.moonspell.app/engine/audio.md). - Backend: board `lantern-tag`, lowest first. ### pulse-arena **Pulse Arena**: free-for-all in a neon arena against three armed bots, first to ten eliminations. Hold FIRE to shoot (the same thumb aims), SWAP cycles rifle, shotgun and rockets, aim assist pulls the crosshair onto enemies, everyone respawns. - Shows: the combat kit with no module around it: `CombatWorld`, weapons (`spreadOf`, `isReloading`), bot gunners (`createGunner`, `driveBotWeapon`), `CombatEffects`, and the combat HUD (`Crosshair`, `HitMarker`, `HealthBar`, `AmmoCounter`, `DamageNumbers`, `Killfeed`). - Read first: `examples/pulse-arena/index.tsx`, then [Combat](https://docs.moonspell.app/engine/combat.md). Start here for a shooter rule the shooter module has no mode for (waves, bomb defusal). - Backend: board `pulse-arena`, lowest first. ### tap-pop **Tap Pop**: dots drift around the screen; tap one to pop it before the 30-second timer runs out, with a local best. - Shows: the 2D layer end to end: `GameCanvas2D`, stage units that fit any phone, taps mapped to the stage (`PointerEvent`), the fixed-step loop, tweens (`createTweens`), particles (`createParticles2D`), text, `haptics`, a one-shot sound (`useOneShots`), a seeded random (`makeRng`), a font family from `@/engine/ui/fonts`, and the HUD chrome (`TopBar`, `Banner`, `FinishCard`, `RoundButton`). It also has a dev performance scene (64 tiles and 200 particles). - Read first: `examples/tap-pop/index.tsx`, then [2D layer](https://docs.moonspell.app/engine/2d.md). Start here for any flat game: one-tap arcade, cards, word games. - Backend: board `tap-pop`, highest first. ### sky-runner **Sky Runner**: a side-scrolling run across rooftops drawn from a tile map: coins, spikes that send you back, one-way ledges, a flag at the end. - Shows: the 2D platformer kit: a tile map from strings (`createTileMap`, `drawTileMap`), the platformer controller (`createPlatformer`, `stepPlatformer`, `platformerInput`: coyote time, jump buffer, short hops, wall slides and wall jumps), triggers (`Triggers2D`), a follow camera (`createCamera2D`, `cameraView`), two parallax layers (`drawParallax`), sprite sheets drawn in pixel-art mode (`createSheet`, `createSpriteAnimator`, `pixelZoom`) with vector shapes as the fallback, and an autopilot built on `raycast2D`. - Read first: `examples/sky-runner/index.tsx`: the `MAP` strings and the `autopilot` function. - Assets: `assets/sprites/sky-runner/runner.png` and `roof-tiles.png`. ### moon-chess **Moon Chess**: chess on the 2D layer against three computer rivals at easy, medium and hard who talk, against a friend online, or pass-and-play on one phone. A drawn board and pieces, tap or drag to move, slides, a promotion picker, take-backs. - Shows: the turns kit: `chessRules` and the chess helpers (`createChess`, `playMove`, `legalMovesFrom`, `san`, `toFen`, `undoMove`), the search (`searchMoves`, `pickMove`), the computer opponent (`useOpponent`), online turns (`useTurnRoom`: seats, resign, rematch with colours swapped), room codes and launch links (`makeRoomCode`, `useLaunchRoomId`, `roomInviteUrl`), presence and a leaderboard. - Read first: `examples/moon-chess/index.tsx` (the largest example, about 1300 lines), then [Turns](https://docs.moonspell.app/engine/turns.md). For another board game, keep its screens and flow and swap `chessRules` for your own `TurnRules`. - Backend: the rivals' lines come from the backend's model (`POST /ai/turn`, see [AI opponent](https://docs.moonspell.app/backend/ai-opponent.md)); offline the search still plays, silently. Online games need rooms. ## Picking a starting point | You want | Copy | | --- | --- | | a block, merge, match or sort puzzle | moon-blocks, moonrise or gem-cascade, then change the config | | a shooter with standard modes | flag-rush | | a town builder with a story | oath-of-ashvale | | a 3D platformer or obstacle course | rooftop-hop | | tag, stealth, chase, enemies that patrol | lantern-tag | | a shooter with your own rules | pulse-arena | | any flat arcade, card or word game | tap-pop | | a 2D platformer or runner | sky-runner | | a board game | moon-chess | | a car race | the starter game in `src/game/index.tsx` | --- # Game backend Source: https://docs.moonspell.app/backend/overview > Each game's shared and lasting data lives on its own Cloudflare Worker, made from the template's backend scaffold (Durable Objects, D1, Workers AI), while the device keeps an offline copy; what the scaffold contains, which engine piece uses which route, and how to deploy it to your own Cloudflare account with Wrangler. A Moonspell game keeps anything that must outlive the device, be shared between players, be fair or be live on its own Cloudflare Worker: leaderboards, "N playing now", multiplayer rooms, synced saves and the computer opponent's model. The device keeps a copy of everything, so the game opens instantly and plays offline, and the engine syncs it when it can. The Worker comes from a scaffold in the template, `templates/crescent-game/.claude/skills/realtime-backend/scaffold/backend/`. The game talks to it through `src/engine/net` and `src/engine/save`. With no backend (`GAME_BACKEND_URL = null`) the same engine calls work on the device alone, so you can build the whole game first and add the backend later. ## Turn it on 1. Copy the scaffold into the game repo as `backend/` and deploy it (steps below). 2. Set the Worker's URL in `src/engine/net/config.ts`: ```ts export const GAME_BACKEND_URL: string | null = 'https://my-game--api.<your-subdomain>.workers.dev'; ``` Leaderboards, presence, rooms, save sync and the opponent's lines turn on by themselves. Nothing else in the game changes. ## What the scaffold contains ```text backend/ CLAUDE.md where the game's data lives: Cloudflare pieces, rules, recipes wrangler.jsonc the Worker, 4 Durable Object classes, D1 database DB, Workers AI as AI package.json scripts: dev, deploy, types, typecheck, db:local, db:remote, smoke src/index.ts the routes (Hono) src/room.ts GameRoom: one per room code src/presence.ts Presence: one per game ("N playing now", quick match, /ai/turn limits) src/leaderboard.ts Leaderboard: one per board, BOARDS config, D1 reads and writes src/saves.ts PlayerSaves: one per player, save slots in its own SQLite src/ai.ts the computer opponent on Workers AI src/protocol.ts message types, identical to src/engine/net/protocol.ts src/save-protocol.ts save sync types, identical to src/engine/net/save-protocol.ts src/util.ts ids, hashing, name cleaning, socket helpers migrations/0001_init.sql D1 tables: scores (every score) and best (each player's best per board, with its ghost) test/smoke.mjs HTTP and WebSocket checks, locally or --read-only against a deployed Worker ``` ### Bindings From `wrangler.jsonc`: | Binding | Kind | What it holds | | --- | --- | --- | | `ROOMS` | Durable Object, class `GameRoom` | One object per room: who is in, states, events, shared room state. | | `PRESENCE` | Durable Object, class `Presence` | One object (`global`) for the game: the live player count, quick-match rooms, the opponent's rate limits. | | `LEADERBOARD` | Durable Object, class `Leaderboard` | One object per board: the live top list in front of D1. | | `SAVES` | Durable Object, class `PlayerSaves` | One object per player: their save slots in SQLite. | | `DB` | D1 database | Tables `scores` and `best`. | | `AI` | Workers AI | The models behind `POST /ai/turn`. | The migrations list two tags: `v1` creates `GameRoom`, `Presence` and `Leaderboard`; `v2` creates `PlayerSaves`. A new class always gets the next tag. Never rename or delete a deployed class: `deleted_classes` destroys its data. ### Routes | Route | What | | --- | --- | | `GET /health` | `{ ok, protocol, time }` | | `GET /rooms/:roomId` | WebSocket: join a room (`?player=<secret id>&name=&spectate=1`). A plain GET returns `{ players, spectators, maxPlayers, open }`. | | `POST /match?size=4` | Quick match: `{ roomId, seat, size }`, the same code for everyone asking within 45 seconds until the room is full. | | `GET /presence` | WebSocket: `{ t: "presence", count }` on every change. A plain GET returns `{ count }`. | | `GET /leaderboard?board=&limit=` | `{ board, order, entries, you }`; `you` when the `x-player-id` header is sent. | | `GET /leaderboard/live?board=` | WebSocket: `{ t: "top", entries }` now and on every change. | | `POST /scores` | `{ board?, playerId, name, score, meta?, ghost? }` returns `{ ok, improved, best }`. | | `GET /ghost?board=&player=` | A player's best run's replay; without `player`, the board's first place. | | `GET /saves` | The player's slot headers (`x-player-id`). | | `GET /saves/:slot`, `PUT /saves/:slot` | One slot; a write from the revision the copy last saw, or a 409 with the server's copy. | | `GET /saves/live?player=` | WebSocket: `hello`, then every write to the player's saves. | | `POST /ai/turn` | The computer opponent's turn: `{ move, say, model, ms }`. | Every socket uses the WebSocket Hibernation API, so an idle room or presence socket costs nothing while it stays open. ## Which engine piece uses what | Engine piece | Calls | On the backend | Page | | --- | --- | --- | --- | | `useLeaderboard`, `submitScore`, `fetchGhost` | `/leaderboard`, `/leaderboard/live`, `/scores`, `/ghost` | `Leaderboard` per board, D1 | [Realtime](https://docs.moonspell.app/backend/realtime.md) | | `usePresence` | `/presence` | `Presence` | [Realtime](https://docs.moonspell.app/backend/realtime.md) | | `useRoom`, `useTurnRoom`, quick match | `/rooms/:roomId`, `/match` | `GameRoom` per room | [Realtime](https://docs.moonspell.app/backend/realtime.md) | | `defineSave`, `defineProgression`, module progress | `/saves`, `/saves/:slot`, `/saves/live` | `PlayerSaves` per player | [Saves sync](https://docs.moonspell.app/backend/saves-sync.md) | | `gameNow()` | the `time` in save responses | the server's clock | [Saves sync](https://docs.moonspell.app/backend/saves-sync.md) | | `useOpponent`, `requestTurn` | `/ai/turn` | Workers AI, limits in `Presence` | [Computer opponent](https://docs.moonspell.app/backend/ai-opponent.md) | | `getPlayerId()` | `x-player-id` header, `?player=` on sockets | hashed before use: `publicId` (what others see), `saveOwner` (names the player's saves) | [Net](https://docs.moonspell.app/engine/net.md) | The client API for each piece is on its engine page: [Net](https://docs.moonspell.app/engine/net.md), [Saves](https://docs.moonspell.app/engine/saves.md), [Turns](https://docs.moonspell.app/engine/turns.md). ## The device keeps a copy | Piece | Without a backend, or offline | | --- | --- | | Leaderboard | This device's own runs, same API. A score that could not be sent waits on the device and goes out with the next leaderboard call. | | Presence | `status: 'local'`; hide the "playing now" chip. | | Rooms | A room with only this player: play against bots or pass-and-play. | | Saves | Kept on the device (MMKV), same API. They start syncing once a backend exists. | | Computer opponent | The search plays its own move, with no lines. | ## Deploy the scaffold to your own Cloudflare account Inside the Moonspell app, the hosted pipeline deploys each game's backend through a private gateway. Outside it you deploy to your own account with Wrangler. These steps follow the scaffold's files and Wrangler's documentation; they were not run end to end for this page, so treat them as untested and check each output. You need Node.js 22 or later (the smoke test uses Node's built-in `fetch` and `WebSocket`) and a Cloudflare account. Durable Objects with SQLite storage are what the scaffold uses. **1. Copy the scaffold** from the game repo's root: ```bash cp -R .claude/skills/realtime-backend/scaffold/backend backend cd backend npm ci ``` `npm ci` installs Hono, Wrangler 4.155.0 and TypeScript from the scaffold's lockfile. **2. Log in to Cloudflare:** ```bash npx wrangler login ``` **3. Name it and create the database.** `wrangler.jsonc` has two placeholders. `__PREFIX__` starts every name: the Worker is `<prefix>api` and the database `<prefix>db`. On your own account the prefix only needs to be unique among your Workers; the scaffold's comment uses the form `sunset-sprint--`. Create the database with that name and note the `database_id` Wrangler prints: ```bash npx wrangler d1 create my-game--db ``` Then fill in both placeholders (macOS and Linux): ```bash sed -i.bak "s/__PREFIX__/my-game--/g; s/__D1_DATABASE_ID__/<database_id>/" wrangler.jsonc && rm wrangler.jsonc.bak ``` **4. Shape it to the game.** Put every leaderboard name the game uses in `BOARDS` in `src/leaderboard.ts` ([Realtime](https://docs.moonspell.app/backend/realtime.md#leaderboards-and-the-boards-rule)). Room settings (`MODE`, `TICK_HZ`, `MAX_PLAYERS`) are at the top of `src/room.ts`. **5. Typecheck** (this also runs `wrangler types`, which writes `worker-configuration.d.ts`): ```bash npm run typecheck ``` **6. Test locally.** Apply the migration to the local database, start the dev server in one terminal, and run the smoke test in another: ```bash npm run db:local npm run dev ``` ```bash node test/smoke.mjs ``` `npm run dev` is `wrangler dev --local`, which serves `http://127.0.0.1:8787` and keeps every binding local. Workers AI has no local simulator, so locally `/ai/turn` answers 503 and the smoke test skips it. [Computer opponent](https://docs.moonspell.app/backend/ai-opponent.md#local-development) has the options. **7. Deploy:** ```bash npm run db:remote npm run deploy ``` `db:remote` applies `migrations/0001_init.sql` to the D1 database on Cloudflare; `deploy` uploads the Worker and creates the Durable Object classes from the migration tags. Wrangler prints the Worker's `workers.dev` URL (`workers_dev` is on in `wrangler.jsonc`). **8. Check it live**, without writing scores: ```bash curl https://my-game--api.<your-subdomain>.workers.dev/health node test/smoke.mjs https://my-game--api.<your-subdomain>.workers.dev --read-only ``` **9. Point the game at it**: set `GAME_BACKEND_URL` in `src/engine/net/config.ts` to that URL. Run `npm run typecheck` and `npm run export:web` in the game afterwards. `backend/` is excluded from the game's TypeScript project, and the template's `.gitignore` already ignores `backend/node_modules/`, `.wrangler/` and `.dev.vars*`. Commit `backend/` with the game. Later changes are the same loop: change, `npm run typecheck`, test locally, deploy, check live. ## Rules the scaffold keeps From `backend/CLAUDE.md`: - **The server decides what has to be fair**: match results, trades, rewards with real value, daily rewards on the server's clock. The device's copy is trusted for single-player progress. - **Identity** is the device's secret player id. It is hashed before it names anything; the secret is never stored or shown. - **One Durable Object per natural unit** (a player, a room, a board, a world), each with small data. - **Wire files are pairs**: `src/protocol.ts` and `src/save-protocol.ts` stay byte-identical to their copies in `src/engine/net/`. Check with `cmp backend/src/protocol.ts src/engine/net/protocol.ts`. - **Secrets and prompts stay in the Worker** (`wrangler secret put`), never in the game's bundle. - **Every route caps size and rate** and validates what it stores. `backend/CLAUDE.md` also maps other Cloudflare pieces to kinds of data (KV for config, R2 for files, Queues, cron triggers, Vectorize, Workflows) and has recipes for a new Durable Object class and an AI character route. ## Gotchas - A board name missing from `BOARDS` answers 404, and those scores stay on the device. See [Realtime](https://docs.moonspell.app/backend/realtime.md). - Never point a shipped build at `http://127.0.0.1:8787`. Use the deployed URL or `null` before `npm run export:web`. - Workers AI usage is billed to your account, including calls made during local development through remote bindings. --- # Realtime Source: https://docs.moonspell.app/backend/realtime > The backend side of leaderboards (and the rule that every board name must be in BOARDS), "N playing now" presence, and multiplayer rooms players join from a link, as the scaffold's Durable Objects implement them. Three things make a game feel inhabited: a leaderboard of other players' scores, a count of who is playing now, and rooms where friends play together. Each is a Durable Object class in the [backend scaffold](https://docs.moonspell.app/backend/overview.md), reached through `src/engine/net`. This page covers what the backend does and what you configure there. The client hooks (`useLeaderboard`, `usePresence`, `useRoom`, `useLaunchRoomId`) are on [Net](https://docs.moonspell.app/engine/net.md). ## Leaderboards and the BOARDS rule Every score goes to `POST /scores` with a board name. The `Leaderboard` Durable Object for that board (one per board) writes it to D1, keeps each player's best in the `best` table and every submission in `scores`, and pushes the new top list to anyone watching `GET /leaderboard/live`. **Every board name the game uses must be in `BOARDS`** in `backend/src/leaderboard.ts`, either exactly or as a family. A board the backend does not know answers 404, and its scores never leave the device. ```ts export const BOARDS: Record<string, BoardConfig> = { global: { order: "desc", min: 0, max: 1_000_000_000 }, // The puzzle module (definePuzzleGame): endless scores, the level reached, one board per day. // Keep them for a puzzle game; delete them otherwise. endless: { order: "desc", min: 0, max: 100_000_000 }, levels: { order: "desc", min: 1, max: 10_000 }, "daily-*": { order: "desc", min: 0, max: 100_000_000 }, // The kingdom module (defineKingdomGame): kingdom power, highest first (its `board` option renames it). // Keep it for a kingdom game; delete it otherwise. "kingdom-power": { order: "desc", min: 0, max: 10_000_000 }, }; ``` That is the scaffold as shipped. Each entry has: | Field | Meaning | | --- | --- | | `order` | `"desc"`: higher wins (points). `"asc"`: lower wins (times). | | `min`, `max` | The score range a real run can reach. A score outside it is refused with a 400. Tighten it: it is the cheapest anti-cheat there is. Times arrive in milliseconds (the examples send `Math.round(elapsed * 1000)`), so a time board's range is in milliseconds too. | A key ending in `*` is a **family**: `"daily-*"` covers `daily-2026-10-31` and every other day, so boards with a date, a track or a level in the name need one entry. When several families match, the longest prefix wins. Board names are 1 to 32 lowercase letters, digits or `-`. The board name comes from the game's `useLeaderboard(url, { board })` call; without `board` it is `global`. Names the engine's modules and examples use: | Game | Board names | Entry to add | | --- | --- | --- | | Puzzle module | `endless`, `levels`, `daily-YYYY-MM-DD` | Already in the scaffold. | | Shooter module | `<config.id>-kills` | `"flag-rush-kills": { order: "desc", min: 0, max: 500 }` | | Kingdom module | `config.board`, default `kingdom-power` | Already in the scaffold (tighten `max` to the game's power). A renamed board needs its own entry. | | Rooftop Hop, Lantern Tag, Pulse Arena | the game's name, run time in milliseconds | `"rooftop-hop": { order: "asc", min: 1_000, max: 3_600_000 }` | | Tap Pop | `tap-pop` | `"tap-pop": { order: "desc", min: 0, max: 10_000_000 }` | | Moon Chess | `global` | Already in the scaffold. | | Racing module | not wired yet | Add an `onFinish` callback in the game's copy of the racing module, then submit lap times in milliseconds to a family such as `"lap-*": { order: "asc", min: 5_000, max: 3_600_000 }`. | To find every board a game uses: ```bash grep -rn "useLeaderboard" src/game src/engine/modules ``` ### What else the backend enforces - One score per player per 2 seconds (429 otherwise). - `meta`: at most 1 KB of JSON, shown with the entry (lap count, level). - `ghost`: an opaque replay string of at most 64 KB, kept only when the score is the player's new best. `GET /ghost` returns a player's, or the board's first place. A ghost is usually `[t, x, z, heading]` samples at 10 Hz. - Names are cleaned to 20 characters. Players are stored by a hash of their secret id (`publicId`), never the id itself. ## Presence: "N playing now" `GET /presence` is a WebSocket to one `Presence` object for the whole game. Every open copy of the game holds a hibernating socket there and receives `{ t: "presence", count }` when the count changes, at most once a second. A plain GET returns `{ count }`. There is nothing to configure. The object's comment says one is enough up to thousands of concurrent players. The same object hands out quick-match rooms: `POST /match?size=4` returns `{ roomId, seat, size }`, the same code to everyone asking within 45 seconds until the room has `size` players (2 to 16). The game asks with `quickMatch(GAME_BACKEND_URL, { size })` ([Networking](https://docs.moonspell.app/engine/net.md)). ## Multiplayer rooms `GET /rooms/:roomId` upgrades to a WebSocket on the `GameRoom` object for that code: one object per room, room codes of 1 to 32 letters, digits, `-` or `_` (upper-cased). The socket carries `?player=<secret id>&name=` and `&spectate=1` to watch without a seat. A plain GET returns `{ players, spectators, maxPlayers, open }`. The tuning is at the top of `backend/src/room.ts`: | Constant | Default | Meaning | | --- | --- | --- | | `MODE` | `"relay"` | `"relay"`: each state is forwarded the moment it arrives; each client owns its entity. `"tick"`: states are batched on an alarm every `1 / TICK_HZ` seconds, and `tick()` is the place for a server-authoritative simulation. | | `TICK_HZ` | `10` | Ticks per second in tick mode. | | `MAX_PLAYERS` | `8` | Seats per room; spectators do not count. The shooter module needs it at least as large as `seats`. | Relay is right for racing, runners and party games. Use tick mode when the outcome must be fair or shared (turn order, who grabbed the item, hits). A ticking room never hibernates while players are in it. Limits the room enforces: - A client message is at most 4 KB (more closes the socket with code 4009); shared room state at most 8 KB. - About 40 messages a second per socket; clients send state at 10 to 20 Hz (default 15). - Close codes 4000 (the same player connected elsewhere) and 4001 (room full) stop the client's reconnects. - A room deletes its stored state 30 minutes after the last player leaves. The first message a client gets, `welcome`, is a full snapshot (players, last states, room state); it arrives again after every reconnect. `roomState` holds shared facts such as phase, round and scores, and the host writes it: the non-spectator who joined first. `useTurnRoom` (the turns kit) keeps its seats, moves and results in that room state, so a player who reconnects gets the whole game back. New message types go in `backend/src/protocol.ts`, then the file is copied over `src/engine/net/protocol.ts`. The two must stay identical: ```bash cmp backend/src/protocol.ts src/engine/net/protocol.ts ``` ### Joining from a link A room code travels in a link as `?room=CODE`. On the game side, `useLaunchRoomId()` reads the code from the link the game was opened with, and `roomInviteUrl(code)` builds a link to share. - **Inside the Moonspell app** the shell passes the game its launch link and its public share link, so an invite opens the game straight into the friend's room. - **Outside it** there is no share link by default, so `roomInviteUrl` returns null: show the room code large enough to read out, and let players type it. In a browser, the engine reads the share link from a `share` query parameter on the game's page, and `useLaunchRoomId` also checks the page's own URL for `room=`, so a link such as `https://your-site.example/?room=K7QXM` should open into that room. This browser path follows from the engine's code and has not been tested for this page. The backend side is the same either way: the code names the `GameRoom` object. ## Testing `node test/smoke.mjs` (against `wrangler dev --local`) checks health, presence, rooms, scores, the leaderboard and ghosts, and saves. Add a check for every message or route you add. After deploying, `node test/smoke.mjs <url> --read-only` checks the live Worker without writing scores. See [the deploy steps](https://docs.moonspell.app/backend/overview.md#deploy-the-scaffold-to-your-own-cloudflare-account). --- # Saves sync Source: https://docs.moonspell.app/backend/saves-sync > How engine saves (src/engine/save) sync to the player's own PlayerSaves Durable Object, with revisions, conflicts and merges, live updates to the player's other copies, the server clock, and the limits the backend enforces. A game's saves live on the device first. When the game has a backend, each save also syncs to a `PlayerSaves` Durable Object that belongs to that player alone, and every change shows up live on the player's other copies of the game (a second phone, a browser). The game code does not change: `defineSave` and `defineProgression` sync by themselves once `GAME_BACKEND_URL` is set. This page is the backend side; the save API (`defineSave`, `defineProgression`, `useSave`, `useSettings`) is on [Saves](https://docs.moonspell.app/engine/saves.md). ## Turn it on Deploy the [backend scaffold](https://docs.moonspell.app/backend/overview.md), which includes `PlayerSaves`, and set `GAME_BACKEND_URL`. Every save syncs unless its spec says `sync: false` (device only). `sync` can also be another backend URL. ```ts import { defineSave } from '@/engine'; export const save = defineSave({ version: 1, defaults: { level: 1, coins: 0 }, }); ``` Player settings (`useSettings`: sound, music, haptics, controls) are per device and never sync. ## One object per player `PlayerSaves` (`backend/src/saves.ts`) is named from a SHA-256 hash of the player's secret id (`saveOwner` in `util.ts`), so the secret never names anything stored. Each object keeps the player's slots in its own SQLite table: for each slot its id, revision, the game's save version, when it changed, its epoch, the device that wrote it and the data as JSON. | Route | Body or reply | | --- | --- | | `GET /saves` (header `x-player-id`) | `{ slots: [{ slot, rev, version, at, epoch, device }], time }` | | `GET /saves/:slot` | `{ slot, time }`, or 404 when the slot was never written | | `PUT /saves/:slot` | Body `{ base, version, at, epoch, device, data }`. Reply `{ ok: true, rev, time }`, or 409 `{ ok: false, conflict: true, remote, time }` | | `GET /saves/live?player=` (WebSocket) | `{ t: "hello", time, slots }` on connect, then `{ t: "save", slot }` for every write | Limits, from `save-protocol.ts` and `saves.ts`: | Limit | Value | | --- | --- | | Slot data | 64 KB of JSON | | Slots per player | 8 | | Slot ids | 1 to 32 lowercase letters, digits, `-` or `_` | | Slots per player, counted | every save's slots together (a progression's `main` is a slot of its own) | | Writes | a burst of 10, then 2 a second per player | | Open live sockets per player | 8; the oldest closes past that | ## Which id a save's slot is stored under `:slot` is not the game's slot id as is: it is `saveSlotId(key, slot)` from `save-protocol.ts`. The game's default save (`key: 'save'`) keeps the slot id (`main`, `2`); any other save is stored as `<key>__<slot>` (`progress__main` for a `defineProgression`; a key with dots, colons or capitals gets a short hash, such as the puzzle module's `puzzle-save-…__main`). Each id is a valid slot id, so every backend stores it as is. An authoritative RPC that changes a progression on the server reads and writes `progress__main`. Engines before 2026-11-11 stored every save under the bare slot id, so a game with two saves had them overwriting each other in one row. What happens to that data now, with no change on the backend (old and new `PlayerSaves` behave the same): - **A save with its own key** (a progression, the puzzle or kingdom module's save) reads the row at the bare id once per device. When the data looks like its own (its fields, compared with every other save the game defines), it merges it in and writes it to its own id. A device that had synced it before sends its copy to the new id on its next sync and merges with whatever another updated device put there first. - **The default save** keeps reading and writing the bare id, as before. When the row there was last written by another save, it ignores that copy and writes its own over it. - The old row is never deleted; it keeps counting toward the 8 slots. - Devices still running an older engine keep writing the bare id until their game updates; then their copies are merged in as above. ## How a sync goes The device's copy is always the one the game reads and writes. The syncer in `src/engine/save/sync.ts` keeps it in step: 1. **On first use** of a slot, it pulls what the server holds. 2. **After a change**, it waits 1.5 seconds (changes in between go together) and sends a `PUT` with `base`: the revision this copy last synced. 3. **The server's rule** (`acceptPut`, shared by both sides): a write must start from the revision the server holds. If it does, the revision goes up by one, the write is stored and pushed at once to the player's other open copies. 4. **A stale `base` is a conflict.** The server answers 409 with its copy. The device merges and writes again: - With a `merge` function in the spec, the two copies are combined by it. `defineProgression` merges field by field. - Without one, the copy changed last (`at`) wins, and the other is kept on the device under `<key>.<slot>.backup`. Nothing is dropped silently. 5. **Starting over** bumps the slot's `epoch`. A higher epoch replaces a lower one instead of merging, so a reset on one device is not undone by an old copy on another. 6. **The live socket** says `hello` with the server's slot headers when it connects, so a copy that was away fetches what it missed; after that each `save` message brings another copy's write. Offline, changes stay on the device and are marked unsent; the syncer tries again 20 seconds later, and when the socket reconnects. `useSyncStatus(save)` returns `local` (no backend), `idle`, `syncing`, `offline` or `error`, for a small indicator if the game wants one. The migration rules still apply to synced data: a slot written by an older version of the game runs through the spec's `migrations` when this copy reads it. ## The server's clock Every save response carries the server's `time`. The engine records the offset, and from then on `gameNow()` returns the server's time, so daily rewards and offline earnings cannot be farmed by changing the device's date. Without a backend, `gameNow()` is the device's clock. ## When progress must be fair By default the device's copy is trusted: right for single-player progress. Rewards with real value, trades between players and competitive economies belong on the server. `backend/CLAUDE.md` describes two ways: validate writes in `PlayerSaves.write`, or add an RPC method on the class (`claimDaily`, `buy`) that changes the stored slot on the server and pushes it like any write. ## A backend made before saves sync A `backend/` copied before `PlayerSaves` existed has no `src/saves.ts`. The **realtime-backend** skill lists the steps: copy `saves.ts` and `save-protocol.ts` from the scaffold, copy `saveOwner` into `util.ts`, add the export and `app.route("/saves", saves)` in `index.ts`, add the `SAVES` binding and the next migration tag (`{ "tag": "v2", "new_sqlite_classes": ["PlayerSaves"] }`, or the next free tag) in `wrangler.jsonc`, then test and deploy. ## Testing - `backend/src/save-protocol.ts` and `src/engine/net/save-protocol.ts` must be identical: `cmp backend/src/save-protocol.ts src/engine/net/save-protocol.ts`. - The scaffold's smoke test covers listing, writes, a 409 on a stale revision, live delivery to a second copy, the size limit and that another player cannot read the slot. - On the game side, `src/engine/save/testing.ts` has `memoryStore()` and `memorySaveServer()` (the backend's rules in memory) for unit tests of migrations and merges without a network. See [Saves](https://docs.moonspell.app/engine/saves.md). --- # Computer opponent Source: https://docs.moonspell.app/backend/ai-opponent > POST /ai/turn, the backend route where a Workers AI model plays one turn of a turn-based game in character, choosing among the moves the game's own search offers, and how useOpponent in the turns kit calls it and falls back when it cannot. A turn-based game made with the turns kit gets a computer opponent with a personality: it plays at easy, medium or hard and says a short line each turn. The game's own search on the device finds the moves worth playing; the backend's `POST /ai/turn` route asks a Workers AI model to choose one of them in character and write the line. The model only ever sees legal moves, so it cannot play an illegal one, and when there is no backend or the model does not answer, the search plays its own pick. The client side (`useOpponent`, `createOpponent`, `TurnRules`, levels) is on [Turns](https://docs.moonspell.app/engine/turns.md). This page is the route and how the two meet. ## How useOpponent uses it `useOpponent` takes the game's rules, a level, a persona and the backend URL. This follows how `examples/moon-chess/` sets it up: ```tsx import { chessRules, GAME_BACKEND_URL, useOpponent } from '@/engine'; // Inside a component: the medium rival from Moon Chess, playing Black. export function useVesper() { return useOpponent({ rules: chessRules, game: 'chess', level: 'medium', persona: 'Vesper, a sly night fox who plays quick, tricky chess and teases the player when they slip, but always fairly.', side: 'Black', backendUrl: GAME_BACKEND_URL, }); } ``` Each `opponent.takeTurn(state, history)`: 1. Searches the position on the device (`searchMovesAsync`, so the board keeps animating), to the level's depth and time. 2. Keeps a shortlist of moves a player at that level would consider (`OPPONENT_LEVELS`: hard keeps 3 moves close to the best, easy 6 from a looser range), each with a note such as `+0.4` or `wins in 2`. 3. Sends the shortlist to `POST /ai/turn` through `requestTurn` (from `src/engine/net/turn.ts`), waiting up to 8 seconds by default (`timeoutMs`). 4. Plays the model's choice and returns its line as `say`, with `source: 'model'`. If there is no backend, the request fails, or the answer is not on the list, it plays the search's own pick from the same shortlist, with `say: ''`, `source: 'search'` and a `reason` ("no backend", "timed out", a status code). The level decides how strong the moves on the shortlist are; the model only picks among them. So the opponent stays sharp offline, only silent. ## The route `POST /ai/turn` is in the scaffold's `backend/src/index.ts`, with the logic in `backend/src/ai.ts`. ### Request | Field | Type | Notes | | --- | --- | --- | | `playerId` | string | The device's secret player id (8 to 64 letters, digits, `-` or `_`). `requestTurn` adds it. | | `game` | string | What is being played, for example `"chess"`. Up to 40 characters. | | `persona` | string, optional | Who the opponent is: a name and a personality. Up to 400 characters. | | `level` | string, optional | `easy`, `medium` or `hard` (default `medium` in the prompt). | | `side` | string, optional | The side it plays, for example `"Black"`. | | `position` | string | The position as text: the rules' `describe(state)`, or the list of options. Up to 1500 characters. | | `history` | string array, optional | The game so far in the game's notation; the last 60 moves are kept. | | `options` | array of `{ move, note? }` | 1 to 24 moves; `move` up to 24 characters, `note` up to 80; no move twice. | The request body is at most 16 KB. ### Response `200` with `{ move, say, model, ms }`: `move` is exactly one of the options, `say` is the line (no emoji, one line, at most 120 characters), `model` the model that answered, `ms` how long it took. | Status | Meaning | What the game does | | --- | --- | --- | | 400 | A field is missing or too long; the error names it. | Plays its own move. | | 413 | Body over 16 KB. | Plays its own move. | | 429 | Over the limit: 30 turns per player and 120 per game a minute, counted in the `Presence` object (`allowTurn`). | Plays its own move. | | 502 | No model answered with an option in time. | Plays its own move. | | 503 | No model available here (local development without Workers AI). | Plays its own move. | ### What the Worker does 1. Checks and trims the request (`parseTurnRequest`). 2. Checks the rate limits in `Presence`. 3. Builds a system prompt from the game, persona, side and level (hard: play the best-rated option unless another is as good; medium: now and then a slightly weaker one; easy: natural-looking moves, often not the engine's favourite), and a user message with the position, the game so far, the player's last move and the options with their notes. 4. Calls the models in `TURN_MODELS` in order, `@cf/openai/gpt-oss-20b` (with `reasoning_effort: "low"`) and then `@cf/meta/llama-4-scout-17b-16e-instruct`, asking for JSON that matches a schema whose `move` is an enum of the options. The whole turn has a 7 second budget (`TURN_BUDGET_MS`). 5. Matches the answer to an option (exactly, or ignoring case, spaces and check marks), cleans the line, and returns it. An answer off the list moves on to the next model. Workers AI is the `AI` binding in `wrangler.jsonc` (`"ai": { "binding": "AI" }`); there is no API key in the Worker or the game. Workers AI usage is billed to the Cloudflare account the Worker is deployed to. The scaffold's comments say both models run on the Workers free plan; check the current model list for your account. ## Local development Workers AI has no local simulator. The scaffold's `npm run dev` is `wrangler dev --local`, which keeps every binding local, so `/ai/turn` answers 503 there and the smoke test skips its checks. Two ways to reach real models while developing: - **The REST fallback in `ai.ts`.** When the binding cannot run, the Worker calls `<AI_DEV_BASE_URL>/accounts/<AI_DEV_ACCOUNT_ID>/ai/run/<model>` with `AI_DEV_TOKEN` as a bearer token. Put the three values in `backend/.dev.vars`, which is git-ignored and never deployed. On your own account that is Cloudflare's API base (`https://api.cloudflare.com/client/v4`), your account id, and an API token allowed to use Workers AI. Not tested for this page. - **A remote AI binding.** Cloudflare's docs say that in `wrangler dev` without `--local`, the AI binding connects to the real models while the rest of the Worker runs locally. Run `npx wrangler dev` instead of `npm run dev`. Not tested for this page. Either way the calls use your account's Workers AI and are billed like production calls. After deploying, `node test/smoke.mjs <url> --read-only` checks that `/ai/turn` refuses a turn with no options, plays one of the options, and says a short line. ## A backend made before the opponent A `backend/` copied before `/ai/turn` existed has no `src/ai.ts`. The **realtime-backend** skill lists the steps: copy `ai.ts` from the scaffold, append the "Computer opponent" section of the scaffold's `protocol.ts` to both protocol copies, copy the `allowTurn` method and its fields into `presence.ts` (same class, no migration), add the route in `index.ts`, add `"ai": { "binding": "AI" }` to `wrangler.jsonc`, then test and deploy. ## The same pattern for other AI characters `backend/CLAUDE.md` has a recipe for any AI character route (an NPC who talks, hints, generated quests) on the same binding: strict output, a fallback model, a time budget and a per-player limit, with `/ai/turn` as the worked example. Keep prompts and keys in the Worker, never in the game's bundle. --- # Build with AI Source: https://docs.moonspell.app/ai/overview > What a coding agent gets from a Moonspell game repo (CLAUDE.md, AGENTS.md, 24 skills, agent docs, examples, GDD.md and playtest.json) and why the engine is shaped for agents. The Moonspell engine was built so that a coding agent can make a whole game with it, from an idea to a version you can play. Every game repo starts from the game template in `templates/crescent-game/`, and that template carries everything an agent needs to work in it: standing instructions, skills that hold the know-how for each genre and engine piece, reference docs next to the code, complete example games, and checks the agent can run itself. This section is for two readers: you, setting up Claude Code or another agent on a game, and the agent, reading these pages as Markdown to learn how to work with the engine. ## What an agent gets from a game repo | File or folder | What it is for | | --- | --- | | `CLAUDE.md` | The repo's standing instructions: what can be built and with which engine piece, the rules every game keeps (one game for web, iOS and Android, input through the engine's controls, assets through `require()`, no emoji on screen, clean up on unmount), and how to finish a task. Claude Code reads it at the start of every session. | | `AGENTS.md` | The same instructions under the name other coding agents look for. It is an exact copy of `CLAUDE.md`. | | `.claude/skills/` | 24 skills, one folder each with a `SKILL.md`: routing an idea to a genre, one per genre module, one per engine piece, art and audio, the backend, the playtest. See [Skills](https://docs.moonspell.app/ai/skills.md). | | `.claude/settings.json` | A permission allowlist for Claude Code: `npx tsc`, `npm run typecheck`, the `crescent` CLI and `agent-device`. | | `docs/` | Agent-facing references for the engine, one file per piece: `engine-api.md`, `games-contract.md`, `racing-module.md`, `shooter-module.md`, `puzzle-module.md`, `kingdom-module.md`, `character-controller.md`, `bots.md`, `combat.md`, `2d-layer.md`, `saves.md`, `turns.md`, `cloudflare.md`. Skills point to them for every field and range. | | `examples/` | Eleven complete games, each one file (`examples/<name>/index.tsx`) to copy over `src/game/index.tsx` and change. `examples/README.md` says what each shows. See [Examples](https://docs.moonspell.app/examples.md). | | `src/game/index.tsx` | The game itself: a `GameDefinition` as its default export. The starter is Sunset Sprint, a racing module config. | | `src/engine/` | The game's own copy of the engine. An agent may change or extend it; the change affects only this game. | | `GDD.md` | The game's design document and its memory between sessions. Not in the template: the agent creates it on the first task. See [GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md). | | `playtest.json` | A short script that plays the game with real touches. The template ships one for Sunset Sprint. | | `.claude/skills/realtime-backend/scaffold/backend/` | The game's Cloudflare Worker, copied to `backend/` when the game goes online. See [Backend](https://docs.moonspell.app/backend/overview.md). | ## Why the engine is shaped this way - **Small pieces that compose.** Input, cameras, physics, audio, particles, saves and the network are separate engine pieces with typed props. An agent can read one piece's doc and use it without reading the rest. See the [engine overview](https://docs.moonspell.app/engine/overview.md). - **Genre modules for the common shapes.** A racer, an arena shooter, a puzzle game and a kingdom builder are each one config object (`defineRaceGame`, `defineShooterGame`, `definePuzzleGame`, `defineKingdomGame`). Turning a request into a config is far more reliable than writing a game loop from scratch. See [Game modules](https://docs.moonspell.app/modules/overview.md). - **Skills carry the know-how.** Each skill maps a player's words to the engine's fields ("night, neon" to `timeOfDay: 'night'` and a palette), lists the limits, and says what to do instead. The agent loads only the skill its task needs. - **Docs next to the code.** The template's `docs/` travel with every game repo, so the reference an agent reads matches the engine copy it is editing. - **Checks an agent can run.** `npm run typecheck`, `npm test` (the engine's unit tests) and `npm run export:web` (a full static build) tell the agent whether its change works, without a person in the loop. See [Claude Code and other agents](https://docs.moonspell.app/ai/claude-code.md). - **Small playable steps.** The skills ask for the smallest playable version first, then one change at a time, each one checked and committed. ## Inside and outside the Moonspell app The template is also what the hosted Moonspell app uses: a player chats with Nova, and a Claude Code worker edits the game's repo in a sandbox. Some instructions and skills are written for that worker and mention the `crescent` CLI, the Moonspell app, or art, sound and models generated after the worker's pass. Outside the app you have two choices: - **Engine only, plain Expo.** None of those tools exist. [Claude Code and other agents](https://docs.moonspell.app/ai/claude-code.md) lists which parts assume the hosted pipeline and what to do instead. - **Local developer mode.** You publish to the Moonspell app from your own computer. The starter from moonspell.app rewords the skills for your machine and adds the built `crescent` CLI, a `moonspell-dev` skill and the [Moonspell MCP server](https://docs.moonspell.app/ai/mcp.md#the-moonspell-server). See [Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md). What is open and what is private is on [Open source](https://docs.moonspell.app/start/open-source.md). ## In this section - [Claude Code and other agents](https://docs.moonspell.app/ai/claude-code.md): set up an agent on a game repo, prompts that work, how the agent checks its work. - [Skills](https://docs.moonspell.app/ai/skills.md): every skill, what it is for and when to load it. - [Game router](https://docs.moonspell.app/ai/game-router.md): how an idea becomes a genre route. - [GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md): the design document and the playtest script. - [llms.txt and Markdown pages](https://docs.moonspell.app/ai/llms-txt.md): feed these docs to a model. - [MCP servers](https://docs.moonspell.app/ai/mcp.md): let an agent search these docs, or reach your Moonspell account in local developer mode. --- # Claude Code and other agents Source: https://docs.moonspell.app/ai/claude-code > Step by step, open a Moonspell game repo in Claude Code or another coding agent, ask for a game, let the agent check its own work, and know which skills assume the hosted Moonspell app. This page is the engine-only path: a game repo you copied from the template, with no Moonspell account. To build with your agent and publish to the Moonspell app, start from the starter instead ([Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md)): its instructions already fit your machine, and the step 3 note below is not needed. A game repo made from the template is ready for a coding agent: open it in Claude Code (or any agent that reads `AGENTS.md`), describe the game you want, and the agent builds it with the engine's skills and checks it with the repo's own scripts. This page walks through that, with prompts that work and the parts of the template that only make sense inside the hosted Moonspell app. ## 1. Get the template Each game is its own repo, copied from `templates/crescent-game/`. [Quickstart](https://docs.moonspell.app/start/quickstart.md) has the full setup; in short: ```bash git clone <repository-url> moonspell cp -R moonspell/templates/crescent-game my-game cd my-game git init npm install ``` The template has three placeholders the hosted pipeline fills in when it creates a game: `__GAME_SLUG__` (the `name` in `package.json` and `package-lock.json`, and `GAME_ID` in `src/engine/net/config.ts`) and `__GAME_TITLE__` (the first line of `CLAUDE.md` and `AGENTS.md`). Replace them with your game's slug and title, or ask the agent to do it as its first task. Commit once before the agent starts, so every change after that is a diff you can read. ## 2. Open it in your agent **Claude Code.** Start it in the game folder: ```bash cd my-game claude ``` Claude Code reads `CLAUDE.md` at the start of the session and finds the skills in `.claude/skills/` by their descriptions, loading one when the task matches. You can also name one: "use the game-router skill". **Other agents.** `AGENTS.md` holds the same instructions under the file name other agents look for. It is an exact copy of `CLAUDE.md`. Agents that do not load `.claude/skills/` by themselves can read a skill as a file: tell the agent to read `.claude/skills/game-router/SKILL.md` before anything else, and to read whichever skill that file names next. ## 3. Tell the agent where it is running `CLAUDE.md` is written for the hosted worker: it says to check work with the `crescent` CLI and to finish with a message for a player. In a plain clone there is no `crescent` and no player (the CLI comes only with the local developer mode starter). Start your first message with a short note like this one, or keep it in a project file your agent always reads: ```text This game repo runs outside the Moonspell app. There is no crescent CLI, no Moonspell app, no hosted art, sound or model generation, and no Cloudflare gateway. Check every change with npm run typecheck, npm test and npm run export:web instead of crescent export. Keep the GDD.md and playtest.json habits. Commit after each playable step. ``` ## 4. What to ask Describe the game the way you would to a friend: the genre or a game it is like, the mood, what the player does, what makes it hard. The **game-router** skill turns those words into a route (a module config, an example to copy, or from scratch), so you do not need to name engine pieces. These prompts match routes and skills the template has. **A new game from an idea** ```text Make a parkour game on rooftops at night. The jumps should be hard, and I want a best time I can beat. ``` The router picks `foundation:character` / parkour: the agent copies `examples/rooftop-hop/index.tsx`, follows the **character-game** skill, and writes `GDD.md`. ```text A drag-and-drop block puzzle that clears lines, set in space, with a daily puzzle. ``` Route `module:puzzle`: a `definePuzzleGame` config with `pack: 'block'` and `daily: true`, from `examples/moon-blocks/`, through the **puzzle-game** skill. ```text Chess against a cheeky computer opponent, three levels of difficulty. ``` Route `kit:turns` / board: the agent starts from `examples/moon-chess/` and changes the rivals and their personas (`docs/turns.md`). **Changing a game** ```text Make the race longer: five laps, six rivals, and make them hard to beat. ``` For a module game this is a config change (`laps`, `opponents.count`, `opponents.speed`), following **car-race-game**. The **crescent-game-change** skill tells the agent to keep every field you did not mention. ```text Add guards that patrol and chase me when they hear me land. ``` On a character game this adds bots (`docs/bots.md`) on the same route. **Art and sound** ```text Use the models in assets/models/ for the player and the crates, with the capsule and boxes as the fallback. ``` ```text Plan every sound this game needs from GDD.md and wire placeholder files through the audio mixer. ``` The **models**, **sprites** and **cutout-art** skills expect the hosted pipeline to generate the files (see [What assumes the Moonspell app](https://docs.moonspell.app/ai/claude-code.md#what-assumes-the-moonspell-app) below). Outside it you bring the files and the agent wires them with the fallbacks those skills describe. The **sound-design** skill's planning, cleanup (`ffmpeg`) and wiring steps work anywhere. **Multiplayer and online features** ```text Add a live leaderboard and "N playing now" to the title screen. ``` ```text Let friends join my tag game in a room, with bots filling empty seats. ``` The **realtime-backend** skill copies the backend scaffold to `backend/`. Without a deployed backend, the same engine calls keep everything on the device (`GAME_BACKEND_URL = null`), so the game still works while you build. Deploying to your own Cloudflare account is on [Backend](https://docs.moonspell.app/backend/overview.md). ## 5. How the agent checks its work Outside the Moonspell app these scripts in the template's `package.json` are the checks: | Command | What it proves | | --- | --- | | `npm run typecheck` | `tsc --noEmit` over the game and the engine: no type errors. It also catches misspelled SF Symbol names, which are typed. | | `npm test` | The engine's unit tests (`src/engine/**/*.test.ts`, Node's test runner with `tsx`). Run it after any change in `src/engine/`. | | `npm run export:web` | A full static web build into `dist/` (`expo export --platform web`). A Metro error here means the game does not open. This replaces `crescent export . --out /tmp/crescent-check` from `CLAUDE.md`. | | `npm run web` | The Expo dev server in a browser with WebGPU. This is for you: open it, play, and tell the agent what you saw. | `npm run ios` and `npm run android` make a normal Expo development build of the game for a simulator or phone. See [iOS](https://docs.moonspell.app/platforms/ios.md) and [Android](https://docs.moonspell.app/platforms/android.md). A save test, when the game keeps progress, runs on its own: `node --import tsx --test src/game/progress.test.ts` (the **saves** skill). The agent cannot see or hear the game. Typecheck, tests and the export prove it builds; whether it plays well is for you to judge in the browser or on a phone. The **sound-design** skill says the same about audio: ask a person. ## 6. Work in small playable checkpoints The skills ask for the smallest playable version first, then one change at a time. Keep that habit outside the hosted pipeline too: 1. One request per step: "a car that drives around a loop", then "add rivals", then "add a leaderboard". 2. After each step the agent runs `npm run typecheck` and `npm run export:web`, and `npm test` when it touched `src/engine/`. 3. It updates `GDD.md` and, when screens, labels or controls changed, `playtest.json`, in the same commit. 4. It commits with a message about what changed for the player, and the route on its own line (`Route: module:puzzle`), as the **game-router** skill asks. If a step breaks the game, `git` takes you back to the last commit that played. ## What assumes the Moonspell app These skills and instructions were written for the hosted worker. They are not wrong, but their tools do not exist outside the Moonspell app. | Skill or file | What it assumes | Outside the Moonspell app | | --- | --- | --- | | `CLAUDE.md`, **crescent-game-change**, the genre skills' checklists | `crescent export . --out /tmp/crescent-check` and `crescent doctor` as the gate; a one-paragraph summary for a player | Use `npm run typecheck`, `npm test` and `npm run export:web`. There is no `crescent doctor`. | | **push-to-device** | `crescent push` to a Moonspell app connected to a local `crescent start` server | Does not apply. Run `npm run web`, `npm run ios` or `npm run android` and look at it yourself. | | **native-now-modules** and the "native is fixed" rule in `CLAUDE.md` | The game runs in the prebuilt Moonspell shell, so only the native modules compiled into it exist; `crescent catalog` lists them | The list in `references/modules.md` is still the set the template's `package.json` pins. In your own development build you can add a native module, but then the game no longer runs inside the Moonspell app. Keep the pinned versions as they are. | | **models**, **sprites**, **cutout-art** | You list files in `~/.crescent/models.json` or `~/.crescent/sprites.json`, write placeholders with `~/.crescent/helpers.cjs`, and the hosted pipeline generates the real files after your pass | Neither the lists nor the helper exist. Put your own files at the paths (a `.glb` in `assets/models/`, a PNG sheet in `assets/sprites/` with the grid you pass to `createSheet`), or keep the fallbacks. Any 1x1 PNG works as a sprite placeholder: the engine treats it as "not art yet" and draws your fallback. | | **sound-design**, **puzzle-game**, **kingdom-game** | Sounds listed in the run's "Sounds" section or a `sounds.json` are generated after your pass | Bring your own audio files. Planning, the `ffmpeg` cleanup and the mixer wiring work as written. | | **realtime-backend** | A gateway token (`CLOUDFLARE_API_TOKEN`), the `mila-cloudflare` MCP tools (`cloudflare_project`, `cloudflare_create`) and a resource prefix | Deploy the scaffold with Wrangler to your own Cloudflare account: [Backend](https://docs.moonspell.app/backend/overview.md). The scaffold, routes, local tests and client API are the same. | | **playtest** | A Mac in the hosted pipeline plays `playtest.json` on a simulator and records the feed video | Nothing runs it for you. Keep the file correct so the labels stay testable; see [GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md). | | **kingdom-game** "Device QA", `examples/README.md` | `crescent start --sim --dev` to load a game into the Moonspell app on a simulator | Use `npm run web` or a development build. | | `npm start`, `npm run export`, `npm run push`, `npm run doctor` | These scripts call the `crescent` CLI | They fail in a plain clone, which has no CLI. Use the scripts in the table in step 5. (The local developer mode starter includes the built CLI.) | The engine itself does not depend on the Moonspell app: where the shell is absent, the engine treats it as optional, and `roomInviteUrl` returns null because there is no share link. See [Platform layer](https://docs.moonspell.app/engine/platform-layer.md). --- # Skills Source: https://docs.moonspell.app/ai/skills > Every skill in the game template's .claude/skills folder, grouped by job, with what it is for, when an agent should load it, what it produces, and which ones assume the hosted Moonspell pipeline. A skill is a folder in `templates/crescent-game/.claude/skills/` with a `SKILL.md`: a short front matter (`name`, `description`) and the instructions for one job. Claude Code matches a task to a skill by its description and loads only that one. Other agents can read the file directly. Some skills have reference files next to `SKILL.md`. The template has 24 skills. Start with **game-router** for any new game or change of genre; it names the next skill to read. Skills marked **hosted** were written for the Moonspell app's worker and use tools that do not exist outside it. What to do instead is on [Claude Code and other agents](https://docs.moonspell.app/ai/claude-code.md#what-assumes-the-moonspell-app). ## Routing and design | Skill | What it is for | Load it when | What it produces | | --- | --- | --- | --- | | `game-router` | Maps the player's words to a genre and a route: a module config, an example to copy, or from scratch, with the camera, controls, bots and online defaults for that genre and what is not possible yet. | First, for any request that creates a game or changes what kind of game it is. | A route (`module:racing`, `foundation:character`, `kit:turns`, `scratch`...) written as one line in the commit message. See [Game router](https://docs.moonspell.app/ai/game-router.md). | | `crescent-game-change` | The procedure for any change: read `GDD.md`, route, read before writing, the smallest change that does what was asked, game UI, phone rules, verify, commit. | Every task in a game repo. | A checked, committed change. Its verify step uses `crescent export`; outside the app use `npm run export:web`. | | `game-design-doc` | Creates, reads and grows `GDD.md`: pitch, pillars, controls, a table of dynamics (trigger, move, camera, world, sound), rules, assets. Also how to read a reference video into moments with `ffmpeg`. Reference file: `gdd-template.md`. | Start of every task; whenever someone gives a reference video, image or game to match. | `GDD.md` at the repo root, updated in the same commit as the code. See [GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md). | ## Genres | Skill | What it is for | Load it when | What it produces | | --- | --- | --- | --- | | `car-race-game` | Vibe words to a `defineRaceGame` config: time of day, scenery, track preset or custom points, car tuning, opponents, post-processing look. | The game is or becomes a car race (`module:racing`). | A race config in `src/game/index.tsx`. See [Racing](https://docs.moonspell.app/modules/racing.md). | | `shooter-game` | Words to a `defineShooterGame` config: mode (`ffa`, `tdm`, `gun-game`, `ctf`, `koth`), map, loadout, bots, pickups, multiplayer. | The router picks `module:shooter`. | A shooter config, started from `examples/flag-rush/`, plus the `<id>-kills` board for the backend. See [Arena shooter](https://docs.moonspell.app/modules/shooter.md). | | `puzzle-game` | Words to a `definePuzzleGame` config: pack (`block`, `merge`, `match`, `sort`), mode, difficulty, theme, drawn tiles, sounds. | The router picks `module:puzzle`, and every change to a puzzle game. | A puzzle config from `examples/moon-blocks/` or `examples/moonrise/`. See [Puzzle](https://docs.moonspell.app/modules/puzzle.md). | | `kingdom-game` | Words to a `defineKingdomGame` config: theme, buildings on timers, crops, hero classes, missions, and the required story chapters. | The router picks `module:kingdom`, and every change to a kingdom game. | A kingdom config from `examples/oath-of-ashvale/`, plus model and cut-out lists. See [Kingdom](https://docs.moonspell.app/modules/kingdom.md). | | `character-game` | Words to a level, character params, camera, touch controls, bots and weapons on the character foundation: parkour, arena, tag, stealth, horror, top-down, side-view, melee. | The router picks `foundation:character`. | A game copied from `examples/rooftop-hop/`, `examples/lantern-tag/` or `examples/pulse-arena/` and changed. See [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md). | | `platformer-2d` | Flat 2D platformers, side-scrollers, runners and top-down games on the 2D layer: tile maps from strings, the platformer and top-down controllers, triggers, parallax, animated sprites. | A side-scrolling run-and-jump game, a 2D dungeon or maze. | A game started from `examples/sky-runner/`. See [2D layer](https://docs.moonspell.app/engine/2d.md). | ## Engine pieces | Skill | What it is for | Load it when | What it produces | | --- | --- | --- | --- | | `touch-controls` | The multi-touch rules every control follows: one gesture tracks every finger, no separate `Pressable`s for buttons held together, no window coordinates. Also `<CharacterControls secondThumb />`. | Adding or changing any control or HUD button. | Controls that take several fingers at once. See [Input](https://docs.moonspell.app/engine/input.md). | | `game-ui` | Screens, look and feel: the screen set (title, HUD, pause, results), palette and shapes from the game's theme, one font family from the genre picker, SF Symbol icons through `<Icon>` (never emoji), juice. Reference file: `juice.md` (button, banner, counters, results, coin fly). | Every new game, and any screen, menu, HUD element, button, banner or reward. | UI that matches the game, checked against its "Before you save a checkpoint" list. See [HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md). | | `expo-animation` | Sub-skill of `game-ui`: how to build any React Native animation (should it animate, which thread, which properties, spring or timing, haptics, reduced motion) with Reanimated 4 and Gesture Handler. Vendored from expo/skills under MIT (`LICENSE`); a block at the top adapts it to games. Reference file: `RECIPES.md`. | Writing animation code for menus, HUD pieces, panels or drags, or when motion stutters. | Animations on the UI thread using the engine's motion constants. | | `game-feel` | 3D moments: `shake`, `hitStop`, `slowMotion`, `flash`, `squash`, `stretch`, `pop`, `vanish`, tweens and the `juice()` presets under `<GameFeel>` and `<FeelOverlay>`. | Hits, landings, pickups, explosions, deaths, or a game that "feels flat" in 3D. | Juice in the scene. See [Game feel](https://docs.moonspell.app/engine/game-feel.md). | | `gpu-effects` | TypeGPU particles (`fire`, `sparks`, `smoke`, `magic`, `rain`, `snow`, `dust`, `vortex`), trails, and your own compute shaders on three's device. | Fire, explosions, magic, weather, exhaust, bursts, or thousands of moving things. | `<Particles>` and compute code. See [GPU effects](https://docs.moonspell.app/engine/gpu-effects.md). | | `physics` | Rigid bodies on cannon-es for the few objects that matter, GPU debris for the many small ones, triggers, joints, `<Vehicle>`, with a budget and when to leave physics off. | Things that are knocked over, thrown, dropped, rolled, swung, scattered or picked up. | `<Physics>` with bodies. See [Physics](https://docs.moonspell.app/engine/physics.md). | | `saves` | Progress players come back to: `defineSave`, `defineProgression` (currencies, shop, unlocks, XP, daily streaks, offline earnings), `useSettings`, `<ScoreCard>`, and how to test migrations. | The game keeps anything between sessions, or the player asks for a shop, upgrades, levels, daily rewards or a settings screen. | An engine save that syncs once a backend exists. See [Saves](https://docs.moonspell.app/engine/saves.md) and [Saves sync](https://docs.moonspell.app/backend/saves-sync.md). | | `native-now-modules` | Every native module compiled into the Moonspell app, by category, with exact versions. Reference file: `references/modules.md`. **Hosted**: written for the prebuilt shell (`crescent catalog`, `crescent doctor`). | Choosing a library, or before adding any dependency. | A library choice from the pinned set. | ## Art and audio | Skill | What it is for | Load it when | What it produces | | --- | --- | --- | --- | | `models` | 3D models for characters, vehicles, buildings and props, used through `<Model>`, `CharacterMesh model=` and `<Scenery>` with a fallback; prompts that rig well; the budget. **Hosted**: the files are generated after the agent's pass from `~/.crescent/models.json`. | A specific look (a knight, a red kart, a street of buildings), or capsules and boxes that should become real shapes. | Fallback-first model code and a model list. See [Models and scenery](https://docs.moonspell.app/engine/models-and-scenery.md). | | `sprites` | 2D art: character sheets, items, tilesets, backgrounds and parallax layers, used through `createSheet` with your shapes as the fallback. **Hosted**: drawn after the pass from `~/.crescent/sprites.json`. | A 2D game that should look like more than flat shapes, or a named look (pixel art, a cute fox). | Sheet code with fallbacks and a sprite list. | | `cutout-art` | Cut-out items in a pre-rendered 3D look (resource icons, rewards, build cards, portraits, crops) used through `<SheetImage>` in the HUD and `<Billboard>` in the scene. **Hosted**: drawn after the pass from a `cutouts` entry in `~/.crescent/sprites.json`. | Item pictures or story portraits instead of icons. | Cell-indexed art with fallbacks. | | `sound-design` | The whole sound of a game: list every sound from the GDD's dynamics (body, reactions, rewards, air, music), write prompts, clean files with `ffmpeg`, wire them through `useAudioMixer`. Generation is **hosted** (Seed Audio for `.wav`, Sonilo for `.m4a`, after the pass). | Every new game, every new move or moment, and any request for music, effects or ambience. | A sound plan in `GDD.md` and mixer wiring. See [Audio](https://docs.moonspell.app/engine/audio.md). | ## Backend | Skill | What it is for | Load it when | What it produces | | --- | --- | --- | --- | | `realtime-backend` | The game's Cloudflare Worker: live leaderboards, presence, multiplayer rooms joined from a link, ghosts and spectating, synced saves, the computer opponent (`POST /ai/turn`), and anything that should outlive the phone. Includes the scaffold in `scaffold/backend/` and its `CLAUDE.md`. Its setup steps are **hosted** (a gateway and MCP tools); the scaffold and client code are not. | Any online, shared, multiplayer, high-score or AI-character request, and any change to `backend/` or `src/engine/net`. | `backend/` copied from the scaffold, `BOARDS` filled in, `GAME_BACKEND_URL` set. See [Backend](https://docs.moonspell.app/backend/overview.md). | ## Testing and shipping | Skill | What it is for | Load it when | What it produces | | --- | --- | --- | --- | | `playtest` | Writes and keeps `playtest.json`, a script of real touches (`press`, `hold`, `tap`, `drag`, `expect`, `expectChange`, `mark`, `repeat`). **Hosted**: the Moonspell pipeline runs it on a simulator and its recording becomes the game's feed video. | Every new game, and whenever screens, button labels or controls change. | `playtest.json` at the repo root. See [GDD.md and playtest.json](https://docs.moonspell.app/ai/game-design-doc.md). | | `push-to-device` | `crescent push`: re-export and send the bundle live to a connected Moonspell app, and read its exit codes. **Hosted** only. | A person developing with a Moonspell app connected to a local `crescent start` server. | Nothing outside the Moonspell app: run `npm run web` or a development build instead. | ## Adding a skill A skill for a new genre module also needs a row in the `game-router` skill (trigger words in Classify, a row in Route, the limit removed from section 3) and a line in the template's `CLAUDE.md` skills list. See [Writing a module](https://docs.moonspell.app/modules/writing-a-module.md). --- # Game router Source: https://docs.moonspell.app/ai/game-router > How the game-router skill turns a player's idea into a genre and a route (a module config, an example to copy, or from scratch), with its classify and route tables. The **game-router** skill (`templates/crescent-game/.claude/skills/game-router/SKILL.md`) is the first thing an agent reads for any request that makes a game or changes what kind of game it is. It does two things before any code is written: it classifies the request into a genre, and it picks the route that builds that genre best. Then the agent reads only the skill and doc that route names. A route is one of four kinds: | Route kind | Means | | --- | --- | | `module:<name>` | The genre is a config object for a genre module: `defineRaceGame`, `defineShooterGame`, `definePuzzleGame` or `defineKingdomGame`. See [Game modules](https://docs.moonspell.app/modules/overview.md). | | `foundation:character` | A game on the engine's character controller, cameras, bots and combat kit, copied from an example and changed. | | `kit:turns` | A turn-based board game on the turns kit (`TurnRules`, `useOpponent`, `useTurnRoom`). See [Turns](https://docs.moonspell.app/engine/turns.md). | | `scratch` | Written in `src/game/` with engine primitives: the 2D layer for flat games, `EngineCanvas` for 3D. | Before routing, the skill reads `GDD.md`. If the game has one, its genre and rules hold. A change request keeps the current genre unless it names another one: "turn my race into a space game" is still racing; "add enemies that chase me" to a parkour game is parkour plus bots on the same route; "make it a card game" is a new route with the same title. ## 1. Classify Players often name a well-known game as shorthand; the skill matches those names too. This table describes them by genre instead. | Player says (any of) | Genre | | --- | --- | | race, racing, kart, drift, lap, circuit, car, F1, rally, highway, drive | racing | | parkour, obby, platformer, jump over gaps, rooftops, lava floor, checkpoints, speedrun course, climb | parkour | | arena, brawl, fight in a ring, knock off, sumo, dodgeball, sword duel (no guns) | arena | | tag, chase, hide and seek, cops and robbers, catch me, zombies that chase, infected | tag | | stealth, sneak, guards, steal, heist, escape the asylum, backrooms, monster hunts you, horror, flashlight | stealth | | top-down, dungeon, survivors, horde, waves of enemies, twin-stick, bullet heaven | top-down | | side-scroller, 2D-ish, a classic run-and-jump platformer, run right and jump, endless runner | side-view | | shooter, gun, FPS, deathmatch, team deathmatch, capture the flag, king of the hill (with guns), gun game, paintball, snipers, aim, shoot the bots | shooter | | block puzzle, drag blocks to clear lines, wood blocks on a grid, number merge, merge, match three, gems, pop the groups, tap to blast groups, water sort, ball sort, colour tubes, potions, pour | puzzle | | chess, checkers, draughts, connect four, reversi, othello, tic-tac-toe, gomoku, dots and boxes, mancala, "play against the computer", a turn-based duel on a board | board | | cards, deck, dice, poker, memory, trivia, quiz | cards / menu | | kingdom, village I build and defend, a town builder with hero sorties, a survival city in the snow, a farm game with crops and orders, farm with a story, crops and raids, castle and army, samurai province, colony | kingdom | | builder, city, idle, tycoon, tower defense, resources, upgrade timers (no story, no sorties) | strategy | | darts, bowling, golf, basketball, flick, swipe to throw | sports minigame | ## 2. Route What each genre maps to, where the agent starts, and what it reads next. | Genre | Route | Start from | Then read | | --- | --- | --- | --- | | racing | `module:racing`: a `defineRaceGame` config | `src/game/index.tsx` as shipped (Sunset Sprint) | `car-race-game`, `docs/racing-module.md` | | parkour | `foundation:character` | copy `examples/rooftop-hop/index.tsx` | `character-game`, `docs/character-controller.md` | | arena | `foundation:character` | copy `examples/lantern-tag/index.tsx`, replace lanterns with the arena rule (knock-off, king of the hill) | `character-game`, `docs/bots.md` (health, knockback, swords: `docs/combat.md`) | | tag | `foundation:character` | copy `examples/lantern-tag/index.tsx` | `character-game`, `docs/bots.md` | | stealth / horror | `foundation:character` | copy `examples/lantern-tag/index.tsx`, `corridors` preset, dark theme, fog | `character-game`, `docs/bots.md` | | top-down | `foundation:character` | copy `examples/lantern-tag/index.tsx` with the `top` camera and `look: false` | `character-game` | | side-view | 2D platformer (flat, the default) or `foundation:character` (3D seen from the side) | copy `examples/sky-runner/index.tsx` (the `platformer-2d` skill), or `examples/rooftop-hop/index.tsx` with the `side` camera | `character-game` | | shooter | `module:shooter`: a `defineShooterGame` config (modes `ffa`, `tdm`, `gun-game`, `ctf`, `koth`) | copy `examples/flag-rush/index.tsx`; for a rule no mode covers (waves, bomb defusal) copy `examples/pulse-arena/index.tsx` and use the combat kit directly | `shooter-game`, `docs/shooter-module.md` (weapons: `docs/combat.md`) | | puzzle | `module:puzzle`: a `definePuzzleGame` config (2D, no 3D) | copy `examples/moon-blocks/index.tsx` (block) or `examples/moonrise/index.tsx` (merge, drawn tiles) | `puzzle-game`, `docs/puzzle-module.md` | | board | `kit:turns`: the game as `TurnRules`, `useOpponent`, `useTurnRoom`, drawn on the 2D layer | chess: copy `examples/moon-chess/index.tsx` and change its look, rivals and names; another board game: keep that file's screens and flow, swap in your own `TurnRules` | `crescent-game-change`, `docs/turns.md`, `docs/2d-layer.md` | | cards / menu | `scratch` on the 2D layer (`GameCanvas2D`), no 3D | a new `src/game/index.tsx` (`examples/tap-pop/` shows the layer) | `crescent-game-change`, `docs/2d-layer.md` | | kingdom | `module:kingdom`: a `defineKingdomGame` config | copy `examples/oath-of-ashvale/index.tsx` | `kingdom-game`, `docs/kingdom-module.md`, `cutout-art` for portraits and icons | | strategy | `scratch`: tap UI, `top` camera if 3D | a new `src/game/index.tsx` | `crescent-game-change` and `saves` (buildings as upgrades, currencies, offline earnings) | | sports minigame | `scratch` with `EngineCanvas` (3D), or the 2D layer for a flat game | a new `src/game/index.tsx` | `crescent-game-change`, `touch-controls`, `docs/2d-layer.md` | The `docs/...` paths are the template's own agent docs, inside the game repo. ## Camera, controls and bots per genre The same table in the skill also fixes the defaults, so every game of a genre starts from choices that work on a phone. | Genre | Camera | Controls | Bots | | --- | --- | --- | --- | | racing | the module's chase cam | steer + gas + brake (`controls.layout`) | AI cars (`opponents`) | | parkour | `third` (offer `side` for a 2D feel) | stick + look + Jump | none, or 1 to 2 `coward`s as moving hazards | | arena | `third` | stick + look + Jump (+ `a` for the action) | `guard` / `hunter`, 2 to 5, roster fill | | tag | `third` | stick + look + Jump | `hunter` 2 to 4 (or the player hunts `coward`s) | | stealth / horror | `first` (horror) or `third` | stick + look (+ Jump) | `guard` 2 to 3 on patrol routes or one `hunter`, `hard` hearing | | top-down | `top` | stick + buttons (`a`, `b`) | `hunter`s in waves (`roster` to spawn) | | side-view | 2D camera follow / `side` | stick (x only) + Jump, `look: false` | walkers on the 2D bodies, or `coward` / `guard` | | shooter | `third` (the module's over-the-shoulder rig) or `first` | the module's: stick + look + FIRE + Jump (+ SWAP) | the module's (`seats`, `bots.difficulty`) | | puzzle | none (2D board) | one thumb: drag (block), swipe (merge), tap (match, sort) | none (the dev AUTO plays the solver) | | board | none (2D board) | tap a piece then a square, or drag it | three rivals (easy, medium, hard) with personalities that talk through the backend's model; the search plays alone offline | | cards / menu | none | taps and drags (`onPointer`) | `TurnRules` + `useOpponent` for a turn-based duel; otherwise an AI you write | | kingdom | the module's 3/4 town camera (drag to pan) and top-down sortie rig | tap lots and buttons in the village; the floating `<Joystick>` on sorties | the module's squads and camps | | strategy | `top` or none | taps | none | | sports minigame | fixed camera | swipe / flick (`GameCanvas2D` swipes, or one manual gesture) | optional | ## Online and saved by default Every route gets the online default from the **realtime-backend** skill when a backend is available: a leaderboard (time or score), presence ("N playing now"), and rooms where the genre is versus or co-op (racing ghosts; tag, arena and shooter rooms with bot fill; the shooter module runs its own rooms; board games get a friend's room from a link through `useTurnRoom` and the talking opponent on `/ai/turn`). Single-player genres (parkour, puzzle, cards) get a leaderboard and presence only. Every route that keeps anything between sessions keeps it with the **saves** skill: runners a shop and upgrades, survivors permanent upgrades, idle games offline earnings, RPGs XP and gear. See [Backend](https://docs.moonspell.app/backend/overview.md). ## What the router says is not possible yet The skill's section 3 lists the limits and the closest thing to build instead. In short: - **Shooters**: bomb defusal, escort and waves are not modes; build them on a copy of `ShooterGame.tsx` or on `pulse-arena` and call it a first version. Multiplayer is up to 8 per room, hosted by the oldest player's phone. - **Hordes**: waves of 8 bots or fewer. - **Puzzles**: mechanics beyond block, merge, match and sort (screws, parking jam, hexa boards, word puzzles) get the closest pack. - **Board games**: chess is built in (`chessRules`); others need their `TurnRules` written. No turns across days and no notifications; a room keeps its game for 30 minutes after both players leave. - **Kingdoms**: no alliances, gacha heroes, PvP, shared world map or tower placement. - **Shared worlds**: a Durable Object in the game's backend; without one, the world stays on the device. ## Worked examples These rows are from the skill. | Request | Route | Start | Next read | | --- | --- | --- | --- | | "A night race through a neon city with 5 rivals" | `module:racing` | Sunset Sprint config | `car-race-game` | | "A parkour game on rooftops, make the jumps hard" | `foundation:character` / parkour | rooftop-hop (course preset, `jumpHeight` 1.4, gaps near 3 m) | `character-game` | | "Sneak through a dark maze, guards hear your steps, first person" | `foundation:character` / stealth | lantern-tag (corridors preset, `first` camera, 3 `guard`s `hard`, loud footsteps) | `character-game` + `docs/bots.md` | | "Capture the flag with my friends, neon, bots when nobody's on" | `module:shooter` | flag-rush (`mode: 'ctf'`, neon theme, `seats: 6`) | `shooter-game` | | "Tag with my friends, bots fill in when nobody's online" | `foundation:character` / tag | lantern-tag (rooms + `createRoster` fill, `hunter`s) | `character-game` + `realtime-backend` | | "A memory card game with a leaderboard" | `scratch` (Skia) | a new `src/game/index.tsx` | `crescent-game-change` + `realtime-backend` | | "Chess I can play with my friends or against a cheeky computer" | `kit:turns` / board | moon-chess (rename the rivals, cheeky personas) | `docs/turns.md` + `realtime-backend` | | "Connect four against the computer" | `kit:turns` / board | moon-chess's screens and flow, a 7 by 6 board, connect-four `TurnRules` | `docs/turns.md` | ## Writing the route down The agent puts the route on its own line in the commit message (`Route: foundation:character / stealth`) and keeps anything it tells the player about the game, not the route. ## Adding a route A new module or foundation is one row in the Route table (genre, route, what to copy, camera, controls, bots, docs), its trigger words in Classify, and its limit removed from section 3. See [Writing a module](https://docs.moonspell.app/modules/writing-a-module.md). --- # GDD.md and playtest.json Source: https://docs.moonspell.app/ai/game-design-doc > The two files every game repo keeps for its agent, GDD.md (the game's design document and memory between sessions) and playtest.json (a script of real touches that proves the game plays), with their formats and what runs them. Two files at the root of a game repo carry what an agent cannot get from the code: `GDD.md` says what the game is meant to be and feel like, and `playtest.json` says how to prove it plays. Both are updated in the same commit as the code they describe. ## GDD.md `GDD.md` is the game's design document and its memory. An agent starts each session knowing only what is written in the repo, so anything worth keeping goes here: what makes this game this game, the decisions already made and why, and every problem a player reported so it never comes back. The **game-design-doc** skill (`.claude/skills/game-design-doc/SKILL.md`) sets the habit for every task: 1. Read `GDD.md` first. If it is missing, create it from `gdd-template.md` in the skill's folder. 2. Do the work inside it: its rules hold unless the player asks otherwise. 3. Update it before finishing, in the same commit as the code. The template repo has no `GDD.md`; the agent writes one on the first task, from your words or a reference. ### The format This is `gdd-template.md`, the skeleton the agent fills in: ```markdown # <Game title>: Game Design Document **Pitch:** one sentence a player would repeat to a friend. **Reference:** what it's trying to feel like, and which parts to take from it. ## Pillars Two to four sentences. When a decision is close, these break the tie. ## The player - **Character:** who they are and what they look like. - **Controls:** what each thumb does, and how they learn it. - **Core loop:** what you do over and over, and why it's fun. - **Goal and failure:** how you win, what happens when you fail, what you keep. ## Dynamics | Moment | Trigger | Move | Camera | World | Sound | State | |---|---|---|---|---|---|---| State is built, next or not possible yet. ## Camera The default angle, what the player can change, when it returns, and which moments take over. ## World Look (palette, materials, light, what's far away), the areas in order, and what's alive in them. ## Sound Music, ambience, and what you should be able to hear when you close your eyes. ## Rules Things that must always hold, each one a line. Add one when the player reports something wrong. ## Assets What's generated, how, and anything to know before regenerating it. ## Not built yet What's next, in order. ``` ### Dynamics: the part that matters most The skill's main point: a game feels like a tech demo when a character runs and jumps through a static world. The cure is a list of dynamics, small moments where something specific happens, each with all five parts. This is the skill's own example: | Part | Question | Example: hanging from a cliff | | --- | --- | --- | | Trigger | What starts it? | Jumping at a wall too tall to clear | | Move | What does the character do, and who's in control? | Leaps arms-up, catches the edge, hangs until the player climbs or lets go | | Camera | Where does the view go? | Swings to the side, so you see the drop | | World | What reacts? | Pebbles fall, dust puffs | | Sound | What do you hear? | Palms slap on rock; wind gets louder | In `GDD.md` that moment is one row of the Dynamics table: ```markdown | Moment | Trigger | Move | Camera | World | Sound | State | |---|---|---|---|---|---|---| | Cliff hang | Jumping at a wall too tall to clear | Leaps arms-up, catches the edge, hangs until the player climbs or lets go | Swings to the side to show the drop | Pebbles fall, dust puffs | Palms slap on rock; wind gets louder | next | ``` The skill aims for one new dynamic every 20 seconds of play, each different. A moment missing its camera, world or sound is half built. ### Keeping it useful - **Rules are scar tissue.** When you report a problem, the agent fixes it and adds a line under Rules ("walls are solid all the way down") so it stays fixed. - **Change what stopped being true.** A wrong GDD is worse than none. - **Keep it short**: a page or two of facts, no history of attempts (that is what commits are for), no secrets or player data. - **Reference videos**: the skill pulls frames with `ffmpeg -i ref.mp4 -vf fps=2 frames/%03d.jpg` and fills the five parts for each moment it sees. Study a reference; never put someone else's characters, art or footage in the game. Other skills start from the GDD: **game-router** (genre), **game-ui** (look), **character-game** (moves, camera), **sound-design** (the sound list comes from the dynamics), **models** and **sprites** (art direction), **saves** and **realtime-backend** (what lasts and what is shared). ## playtest.json `playtest.json` is a short script that plays the game with real touches and checks that the game answered. The **playtest** skill (`.claude/skills/playtest/SKILL.md`) asks for one in every game and for an update whenever screens, button labels or controls change. ### A real example This is the template's own `playtest.json`, for Sunset Sprint: ```json { "steps": [ { "expect": "Accelerate" }, { "wait": 4 }, { "mark": "playing" }, { "remember": "kilometres per hour" }, { "hold": "Accelerate", "seconds": 3 }, { "expectChange": "kilometres per hour" }, { "repeat": [ { "hold": "Accelerate", "seconds": 4 }, { "hold": "Steer right", "seconds": 0.2 }, { "hold": "Accelerate", "seconds": 4 }, { "hold": "Steer left", "seconds": 0.2 } ], "forSeconds": 22 }, { "expect": "Lap" } ] } ``` It waits for the gas control, marks the start of play, holds the gas, checks that the speed readout changed, drives around for 22 seconds steering gently both ways, and checks that a lap counter shows. ### Steps | Step | What it does | | --- | --- | | `{ "press": "RIDE", "timeout": 20 }` | Taps what has this label, waiting for it (15 s unless `timeout` says otherwise). | | `{ "hold": "GAS", "seconds": 3 }` | Keeps a finger on it. | | `{ "tap": [0.5, 0.7] }` | Taps a point, as fractions of the screen (for a canvas). | | `{ "drag": [[0.2, 0.6], [0.8, 0.6]] }` | A quick swipe between two points. | | `{ "drag": [[0.25, 0.85], [0.25, 0.45]], "seconds": 3 }` | A slow drag that takes that long (10 s at most): how a stick is held over. | | `{ "wait": 2 }` | Waits, in seconds. | | `{ "expect": "Lap 1" }` | Fails unless this label shows up in time. | | `{ "remember": "hud-score" }` | Notes what that label says now. | | `{ "expectChange": "hud-score" }` | Fails unless it changes from what was remembered. | | `{ "mark": "playing" }` | Required. Play has started. | | `{ "repeat": [...], "forSeconds": 25 }` | Loops these steps. A repeat cannot contain another repeat. | | `{ "repeat": [...], "until": "combo", "forSeconds": 25 }` | Loops until this label shows up (or any of a list); fails if none ever does. | At the top level, next to `steps`, an optional `"clipSeconds"` (5 to 30) shortens the recorded clip for a round too short to fill the usual 12 seconds. Times are positive and at most 300 seconds. ### Writing one that works - **Steps find things by `accessibilityLabel` or `testID`**: the exact label first, then one containing the words. Prefer a `testID`: a label like "0" also matches "BEST 0". Text drawn in a Skia or WebGPU canvas is invisible to the test, so give every control you press and every number you check a label on a React Native view. The same labels make the game usable with a screen reader. ```tsx import { Text } from 'react-native'; export function ScoreLabel({ score }: { score: number }) { return <Text testID="hud-score" accessibilityLabel={`Score ${score}`}>{score}</Text>; } ``` - **Start as a new player.** Each test starts with no saves, so write the first-time path (the tutorial, "Begin", not "Continue"). - **Check that the game answered**: a score, a lap, a word on screen. A test that only taps and waits proves nothing. - **Play by rhythm.** Each touch takes about a second to send, so a test cannot react. For a reflex game pick an input loop that survives. - **Mark after the menus**, once the player is in control. If a round can end during the test, press the replay button inside the loop. ### What runs it **Inside the Moonspell app**, after a build lands, a Mac in the hosted pipeline opens the exact build players download on an iOS simulator, plays `playtest.json` with real touches through `agent-device`, and records the screen. If every step passes, about 12 seconds of the recording after `{ "mark": "playing" }` become the game's video on the app's feed; if a step fails, the game keeps its cover. The runner is `scripts/playtest-game.mts` in the Moonspell repo. It needs the Moonspell app, its database and its video account, so it does not run against your own game. **Outside the Moonspell app** nothing runs `playtest.json` for you. It is still worth keeping: it documents the path into play, and the labels it relies on keep the game testable. To check that the file is well formed, run the same parser the pipeline uses, from the root of the Moonspell repo (Node 24 with type stripping): ```bash node --experimental-strip-types --input-type=module -e "import { parsePlaytest } from './src/lib/playtest.ts'; import { readFileSync } from 'node:fs'; const test = parsePlaytest(JSON.parse(readFileSync('templates/crescent-game/playtest.json', 'utf8'))); console.log('ok:', test.steps.length, 'steps')" ``` It prints `ok: 8 steps` for the template's file, or throws with the step that is wrong (for example a file without `{ "mark": "playing" }`). Point it at your own game's `playtest.json` by changing the path. It checks the format only; it does not play the game. --- # llms.txt and Markdown pages Source: https://docs.moonspell.app/ai/llms-txt > Feed these docs to a model with /llms.txt (an index), /llms-full.txt (every page in one file) or the Markdown copy of any page, with examples for curl, a chat and an agent's project file. Every page on this site also exists as plain Markdown, and two files at the root list or contain all of them. Use them to give a model the engine's docs without HTML: paste a page into a chat, add the index to an agent's context, or download everything once. | URL | What it is | Use it when | | --- | --- | --- | | `https://docs.moonspell.app/llms.txt` | The index, in the [llms.txt](https://llmstxt.org) format: a `# Moonspell` heading, a short summary in a blockquote, then one `##` section per sidebar category with a link and a one-line description for each page, in sidebar order. | An agent should find the right page itself. Small enough to keep in context. | | `https://docs.moonspell.app/llms-full.txt` | Every page's Markdown in sidebar order, each one starting with `# <title>` and `Source: <url>`. | You want the whole manual in one file, for a long-context model or a local copy. | | `https://docs.moonspell.app/<path>.md` | The Markdown of one page: front matter removed, the title as the first heading. The path is the page's URL path. | You need one topic, for example `https://docs.moonspell.app/engine/audio.md` or `https://docs.moonspell.app/backend/realtime.md`. | The files are built from the same Markdown sources as the site, so they change when the site does. ## What llms.txt looks like Each section is a sidebar category; each line is a page's title, its `.md` URL and its description. The "Build with AI" section starts like this: ```markdown ## Build with AI - [Build with AI](https://docs.moonspell.app/ai/overview.md): What a coding agent gets from a Moonspell game repo (CLAUDE.md, AGENTS.md, 24 skills, agent docs, examples, GDD.md and playtest.json) and why the engine is shaped for agents. - [Claude Code and other agents](https://docs.moonspell.app/ai/claude-code.md): Step by step, open a Moonspell game repo in Claude Code or another coding agent, ask for a game, let the agent check its own work, and know which skills assume the hosted Moonspell app. ``` ## Read them from a terminal ```bash # The index curl -s https://docs.moonspell.app/llms.txt # One page as Markdown curl -s https://docs.moonspell.app/engine/audio.md # Everything, saved next to your game curl -s -o moonspell-docs.txt https://docs.moonspell.app/llms-full.txt ``` ## Paste into a chat For a question about one engine piece, open its `.md` URL in a browser, copy the text and paste it into the chat with your question. For a broad question ("which module fits a farming game?"), paste `llms.txt` and ask the model to say which pages it needs; then paste those. ## Add them to an agent's context A game repo already carries the template's own docs in `docs/` and the skills in `.claude/skills/`. These site docs add the explanations and the backend and platform pages. Two ways to give them to an agent: **Point to the index.** Add a line to a project file the agent reads, such as the end of `CLAUDE.md` or `AGENTS.md`, and the agent fetches what it needs: ```markdown ## Engine docs The Moonspell engine's docs are at https://docs.moonspell.app/llms.txt (an index). Every page there is also Markdown: add .md to its URL. Read the page for an engine piece before changing how the game uses it. ``` This needs an agent that can fetch URLs. **Keep a local copy.** Download `llms-full.txt` into the repo and import it. In Claude Code, an `@path` line in `CLAUDE.md` loads the file into context at the start of every session: ```bash curl -s -o docs/moonspell-docs.txt https://docs.moonspell.app/llms-full.txt echo "@docs/moonspell-docs.txt" >> CLAUDE.md ``` The full file is large, so this uses a lot of context in every session. Importing a few single pages, saved the same way from their `.md` URLs, is often enough. For search instead of whole files, connect the [docs MCP server](https://docs.moonspell.app/ai/mcp.md). --- # MCP servers Source: https://docs.moonspell.app/ai/mcp > The two Moonspell MCP servers side by side - the public docs server at https://docs.moonspell.app/mcp that searches and reads these docs, and the Moonspell server at https://mcp.moonspell.app/mcp that gives a signed-in developer's agent the engine's skills and their account - with setup for Claude Code, Claude Desktop, Cursor, VS Code and Codex. Moonspell runs two [Model Context Protocol](https://modelcontextprotocol.io) servers. They do different jobs, and you can add both. | | Docs server | Moonspell server | | --- | --- | --- | | URL | `https://docs.moonspell.app/mcp` | `https://mcp.moonspell.app/mcp` | | Sign-in | None. Public, no account or key | A Moonspell account, with Google (OAuth in the browser) | | What it gives the agent | Search and read these docs: `search_docs`, `read_doc`, `list_docs` | The engine's skills and docs, your games, share links with QR codes, invites, and the commands that set up a game folder | | What it cannot do | See your code, your game or your account | Touch your files. Saving and publishing are done by the `crescent` CLI in the game folder | | Suggested name | `moonspell-docs` | `moonspell` (the name the starter's `.mcp.json` uses) | | Use it when | You use the engine on its own (plain Expo), or you want answers from these pages | You build in [local developer mode](https://docs.moonspell.app/start/publish-to-moonspell.md) and publish to the Moonspell app | Both use the Streamable HTTP transport. Give them different names in your client, so one does not replace the other. ## The docs server It is stateless and only reads the public pages of this site, the same content as [llms.txt and the Markdown pages](https://docs.moonspell.app/ai/llms-txt.md). ### Tools | Tool | Input | Returns | | --- | --- | --- | | `search_docs` | `query` (string), `limit` (number, optional, default 8) | Pages ranked by how well they match: title, path, URL, description and the best-matching excerpt. | | `read_doc` | `path` (string), a page's path without `.md`, for example `engine/audio` | The page's Markdown. | | `list_docs` | `section` (string, optional), a sidebar section | Every page, or every page in that section: section, title, path and description. | A typical agent call sequence is `search_docs` with the question, then `read_doc` on the best result. ### Claude Code ```bash claude mcp add --transport http moonspell-docs https://docs.moonspell.app/mcp ``` This adds it for you in the current project. To share it with everyone who opens the game repo, add it at project scope, which writes `.mcp.json` at the repo root (commit it): ```bash claude mcp add --transport http moonspell-docs --scope project https://docs.moonspell.app/mcp ``` ```json { "mcpServers": { "moonspell-docs": { "type": "http", "url": "https://docs.moonspell.app/mcp" } } } ``` Claude Code asks you to approve a project-scoped server the first time it sees it. Run `claude mcp list` to check the connection. ### Claude Desktop Add it as a custom connector: open Settings, choose Connectors, click Add, then Add custom connector, and enter `https://docs.moonspell.app/mcp`. Custom connectors depend on your Claude plan. `claude_desktop_config.json` starts local servers by command, so a remote URL there goes through a local bridge. With the community `mcp-remote` package (needs Node.js): ```json { "mcpServers": { "moonspell-docs": { "command": "npx", "args": ["-y", "mcp-remote", "https://docs.moonspell.app/mcp"] } } } ``` Restart Claude Desktop after editing the file. ### Cursor Add the server to `.cursor/mcp.json` in the project, or to `~/.cursor/mcp.json` for every project: ```json { "mcpServers": { "moonspell-docs": { "url": "https://docs.moonspell.app/mcp" } } } ``` ### VS Code Add the server to `.vscode/mcp.json` in the workspace: ```json { "servers": { "moonspell-docs": { "type": "http", "url": "https://docs.moonspell.app/mcp" } } } ``` The tools are then available to the chat in agent mode. ### Questions to ask once connected - "Search the Moonspell docs: how do I add a live leaderboard, and which board names does the backend accept?" - "Read engine/audio and wire a jump sound and background music into my game." - "List the Game modules pages. Which module fits a farming game with a story?" - "How does a turn-based game's computer opponent work without a backend?" - "What do I need to deploy the game backend to my own Cloudflare account?" The agent answers from the pages it read. For the skills and docs inside the game repo itself, it reads the files directly; the MCP server covers this site. ## The Moonspell server The Moonspell server is the front door of [local developer mode](https://docs.moonspell.app/start/publish-to-moonspell.md). The first time your agent connects, it opens a Moonspell page in the browser that says which app is asking; you allow it and sign in with Google. You can end the connection at any time by revoking its token in Moonspell's Settings, where it is listed as `MCP: <the app's name>`. ### Tools | Tool | What it does | | --- | --- | | `moonspell_status` | Who is signed in through this connection, and which starter the skills and docs come from | | `moonspell_games` | Your games (owned or collaborated on), with the latest share link and the developer page | | `moonspell_game` | One game: its versions, the people building it, its developer page | | `moonspell_create_game` | Registers a new game (no folder yet; `moonspell_start` sets one up) | | `moonspell_links` | The latest version's share link, its QR code as text and as an image, and the developer page | | `moonspell_invite` | An invite link: `play` (anyone with the link plays the latest version) or `build` (owner only: the person becomes a collaborator) | | `moonspell_start` | The commands that set up a game folder on your computer, signed in with a one-time code | | `moonspell_skills`, `moonspell_skill`, `moonspell_skill_file` | The engine's skills: the list, one skill's `SKILL.md`, and its other files | | `moonspell_engine_docs` | The template's `docs/` and its rules | It also serves the skills and docs as resources (`moonspell://skills/<name>`, `moonspell://docs/<topic>`) and two prompts, `new_game` and `change_game`. Inside a game folder, the folder's own `.claude/skills` win. ### Setup ```bash claude mcp add --transport http moonspell https://mcp.moonspell.app/mcp ``` Then run `/mcp` in a Claude Code session, pick `moonspell` and authenticate. The starter's `.mcp.json` already registers it this way. - **Claude Desktop and Claude.ai**: Settings, Connectors, Add custom connector, with `https://mcp.moonspell.app/mcp`. Claude signs in through the same page. - **Cursor**: in `.cursor/mcp.json` or `~/.cursor/mcp.json`, `{"mcpServers": {"moonspell": {"url": "https://mcp.moonspell.app/mcp"}}}`. Cursor registers itself and opens the sign-in. - **Codex**: in `~/.codex/config.toml` (or the project's `.codex/config.toml`), then `codex mcp login moonspell`: ```toml [mcp_servers.moonspell] url = "https://mcp.moonspell.app/mcp" ``` An agent that cannot use a remote server runs the CLI's local server in the game folder instead (`npx --no-install crescent mcp`), with the same tool names plus the ones that touch files ([the commands](https://docs.moonspell.app/start/publish-to-moonspell.md#the-crescent-commands)). How the server works is on [The Moonspell MCP server](https://docs.moonspell.app/moonspell/mcp-server.md). --- # Web Source: https://docs.moonspell.app/platforms/web > How a Moonspell engine game runs in a browser, from the web entry and Skia's CanvasKit to npm run web, a static export in dist/, hosting it, keyboard input, embedding it in a page with an iframe, and the known issues. The web build is the same game source as the phone builds, bundled by Expo for the browser. React Native's views run on react-native-web, three.js draws through the browser's own WebGPU, and Skia draws through CanvasKit, its WebAssembly build. Use it to develop fast (reload on save, browser dev tools) and to put a game on any website. Everything on this page was run in this repository with plain Expo, on macOS in Google Chrome 157, unless a line says otherwise. The Moonspell app loads games in its own way, not with these commands ([What is and is not open](https://docs.moonspell.app/start/open-source.md)). ## Run it From the game's folder (the template is `templates/crescent-game/`): ```bash npm install npm run web ``` Open the URL Expo prints (`http://localhost:8081` by default) in a browser with WebGPU. Arguments after `--` go to `expo start --web`: ```bash npm run web -- --port 8790 ``` The dev server is a development bundle, so `__DEV__` is true: game code that only shows in development (the racing module's AUTO button and telemetry line, for example) is visible here and absent from an export. To make a static site: ```bash npm run export:web ``` It writes `dist/`. To try it locally, serve the folder with any static server, for example: ```bash cd dist && python3 -m http.server 8765 --bind 127.0.0.1 ``` and open `http://127.0.0.1:8765/`. ## The scripts | Script | Runs | What it is for | | --- | --- | --- | | `npm run setup:web` | `setup-skia-web public` | Copies `canvaskit.wasm` into `public/` on its own | | `npm run web` | `setup-skia-web public && expo start --web` | The dev server in the browser | | `npm run export:web` | `setup-skia-web public && expo export --platform web` | A static site in `dist/` | | `npm run typecheck` | `tsc --noEmit` | Type-check the game and engine | | `npm test` | the engine's unit tests | Run before an export | `setup-skia-web` is a bin shipped by `@shopify/react-native-skia`. It copies `node_modules/canvaskit-wasm/bin/full/canvaskit.wasm` (about 8 MB) to `public/canvaskit.wasm`. Expo serves `public/` at the site root in the dev server and copies it into `dist/` on export. The file is in `.gitignore`. Skia's docs say to run the script again after every Skia upgrade; the two scripts above do it on every run. It prints "Metro bundler not detected. Assuming the project is using Webpack.": harmless, the scripts pass the folder explicitly. ## How the browser build starts The template's `package.json` has `"main": "./index"`, so Expo picks `index.web.ts` for the browser and `index.ts` for the phones. No custom `index.html`, Metro or Babel change is needed for the web. `index.web.ts`: ```ts import { bootWeb } from './src/engine/web/boot'; bootWeb(() => require('./src/App').default); ``` `bootWeb` (`src/engine/web/boot.ts`) does this, in order: 1. If `navigator` has no `gpu`, or `navigator.gpu.requestAdapter()` returns no adapter, it replaces the page with "This browser can't run this game yet. Try the latest Chrome, Edge or Safari." and stops. There is no WebGL path. 2. It loads CanvasKit with `LoadSkiaWeb({ locateFile: (file) => webBase() + file })`. `webBase()` is the folder of the page's own URL (`new URL('.', document.baseURI)`), unless the page sets `globalThis.__crescentBase`. A plain Expo export does not set it, so CanvasKit is fetched from `canvaskit.wasm` next to `index.html`. 3. It installs the engine's font fallback for Skia (`src/engine/web/systemFont.ts`): CanvasKit has no system fonts, so every `matchFont()` answers with the engine's bundled Nunito faces. 4. It requires the game and registers it. The game is required inside the callback, not imported at the top: Skia's web build reads CanvasKit the moment it is evaluated. 5. If a step throws, it shows "This game could not start. Reload to try again." and logs the error to the console. [Platform layer](https://docs.moonspell.app/engine/platform-layer.md) covers the per-platform files in more detail. ### The libraries on the web | Piece | Phones | Browser | | --- | --- | --- | | Views, text, `Pressable` | React Native | react-native-web 0.21.4 (DOM elements) | | 3D (`EngineCanvas`) | three.js `WebGPURenderer` on react-native-webgpu (Dawn) | three.js `WebGPURenderer` on the browser's `navigator.gpu`. react-native-webgpu's `Canvas` is a wrapper around an HTML canvas there | | Skia (HUD, 2D layer) | Skia's native build | CanvasKit (`canvaskit.wasm`) | | Gestures | react-native-gesture-handler | its web build | | Sound | react-native-audio-api (the mixer), `expo-audio` (`RaceAudio`, the 2D layer's one-shots) | the same packages' web builds: react-native-audio-api wraps the browser's `AudioContext`, `expo-audio` plays HTML audio elements | | On-device data | MMKV | MMKV's web build, on `localStorage` | The full list of differences is in [Platform differences](https://docs.moonspell.app/platforms/differences.md). How the 3D canvas and the renderer are set up is in [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md). ## Browsers The engine needs WebGPU. Its own notice names current Chrome, Edge and Safari. | Browser | Status | | --- | --- | | Google Chrome 157, macOS, Apple GPU | Tested: dev server and static export, 3D and 2D, touch buttons and keyboard | | Edge, Safari, Firefox | Not tested | | Windows, Linux, Android Chrome, iOS Safari | Not tested | A browser without `navigator.gpu`, or with one that returns no adapter, shows the notice and nothing else. Headless Chrome needs flags to expose WebGPU. What worked on macOS: `--headless=new --enable-unsafe-webgpu --enable-features=WebGPU --use-angle=metal`. A normal desktop Chrome needs no flags. ## What is in `dist/` From one export of the template's default game (16 MB in total): | Path | What | | --- | --- | | `index.html` | Expo's default page. Its title is `expo.name` from `app.json` | | `_expo/static/js/web/index-<hash>.js` | The whole game and engine as one bundle (5.4 MB in this test) | | `assets/...` | The game's audio and models, the Nunito fonts the web boot loads, the Material Symbols font, `react-native-audio-api` icons | | `canvaskit.wasm` | Copied from `public/` (about 8 MB) | | `metadata.json` | Expo's export metadata | There is one JavaScript file because games never use dynamic `import()`: the engine's rule, so a bundle can be served from any folder. ## Hosting a static export `dist/` is a plain static site. Any static host works: object storage behind a CDN, a static site service, your own server. - Serve `.wasm` files as `application/wasm`. - The export uses absolute URLs (`/_expo/...`, `/assets/...`), so put `dist/` at the root of a domain or subdomain. - To host under a sub-path, Expo has the experimental `expo.experiments.baseUrl` in `app.json`, set before exporting ([Expo: hosting with sub-paths](https://docs.expo.dev/more/expo-cli/#hosting-with-sub-paths)). Not tested with the engine. CanvasKit is looked up relative to the page's URL, so serve the page with a trailing slash (`/games/sunset-sprint/`, not `/games/sunset-sprint`): without it, the page's folder is the parent folder and `canvaskit.wasm` is not found. This follows from `webBase()` and was not tested either. - The page fills the browser window (Expo's `#root` is full height). The engine does not frame the game at phone size; if you want a phone-shaped game on a wide screen, put it in an iframe of that size (below). - There is no `favicon.ico` in the export, so browsers log a 404 for it. Harmless; add one to `public/` if you want one. ## Keyboard input A mouse is one finger, so the engine's controls also read the keyboard in a browser (`src/engine/input/keyboard.ts`). `TouchControls`, `Joystick` and `CharacterControls` each listen and feed the same input the thumbs do. A game built on them plays with keys with no change. | Control | Keys | | --- | --- | | `TouchControls` | A or Left steer left, D or Right steer right, W or Up gas, S, Down or Space brake | | `Joystick` | WASD or the arrows, eight directions at full tilt | | `CharacterControls` | WASD or the arrows move; action buttons by position: Space, then J, E or F, then K or Q, then L; a button with id `sprint` is Shift | - Keys are `KeyboardEvent.code` values (the physical key), so WASD stays in place on any keyboard layout. - Keys are ignored while an `input`, `textarea`, `select` or editable element has focus, and while Cmd, Ctrl or Alt is held. - While a control listens, the browser's default for the game keys (scrolling on arrows and Space) is cancelled. - Leaving the tab (`blur`) releases every held key. The full input reference is on [Input](https://docs.moonspell.app/engine/input.md). ## Embedding a game in a page Put the exported game in an `<iframe>` and the engine talks to the page around it with `postMessage` (`src/engine/shellModule.web.ts`). This is the same channel the Moonspell web app uses; the engine side is described here. When the game is not in a frame, none of this runs. The game counts as framed when `window.parent !== window`. ### Messages the game sends Posted to `window.parent` with target origin `*`. Each is a plain object with `crescent: 'game'` and a `type`: | `type` | Extra fields | When | | --- | --- | --- | | `ready` | none | The game is registered and starting | | `error` | `message` (string) | The web boot failed; the frame shows "This game could not start" | | `unsupported` | `reason: 'webgpu'` | No `navigator.gpu`, or no adapter; the frame shows the browser notice | | `exit` | none | The game called `exitToShell()` (the template's exit) | | `pinch` | `scale` (1 down to 0) | Two fingers, or a trackpad pinch, are closing in on the game | | `pinch-end` | `exit` (boolean) | The pinch ended; `true` when it ended past the close threshold | ### Messages the game reads Only messages whose `event.source` is `window.parent` and whose data has `crescent: 'shell'`: | `type` | Extra fields | What the game does | | --- | --- | --- | | `closed` | none | `onShellClosed` listeners run; the template's `App.tsx` unmounts the game (sound, simulation and rendering stop) and shows its "Play" button | | `active` | `active` (boolean) | `isShellActive()` changes and `onShellActive` listeners run; the engine pauses its frame loop, the 2D loop and the audio while it is `false` | ### The query string | Parameter | Read as | | --- | --- | | `?active=0` | The game starts paused (`isShellActive()` is `false`) until an `active` message says `true` | | `?launch=<url>` | `getLaunchUrl()`: the link the game was opened with. `useLaunchRoomId()` reads a `room` code from it | | `?share=<url>` | `getShareUrl()`: the game's public link, used by `roomInviteUrl()` | These parameters work without a frame too. `useLaunchRoomId()` also reads `?room=CODE` from the page's own URL through React Native's `Linking` ([Net](https://docs.moonspell.app/engine/net.md)). ### A framing page A sketch of a page that frames a game, written against the message shapes above. It was not run by the authors. ```html <iframe id="game" src="https://games.example.com/?active=0" style="width: 430px; height: 880px; border: 0" ></iframe> <script> const frame = document.getElementById('game'); const GAME_ORIGIN = 'https://games.example.com'; window.addEventListener('message', (event) => { if (event.source !== frame.contentWindow) return; const data = event.data; if (!data || data.crescent !== 'game') return; if (data.type === 'ready') { frame.contentWindow.postMessage({ crescent: 'shell', type: 'active', active: true }, GAME_ORIGIN); frame.focus(); } if (data.type === 'unsupported') console.log('This browser has no WebGPU'); if (data.type === 'exit') frame.remove(); }); // To stop the game without removing the frame: // frame.contentWindow.postMessage({ crescent: 'shell', type: 'closed' }, GAME_ORIGIN); </script> ``` Things to know when framing: - Keep the frame at a fixed size while the game plays (see Known issues). - Keyboard events go to the focused document: the player has to click into the frame, or the page calls `frame.focus()`, before the keys reach the game. - In a frame, the game installs a pinch watcher on its own window: two fingers that both move toward each other, a Ctrl plus wheel event (how Chrome, Edge and Firefox report a trackpad pinch), or Safari's gesture events. It cancels the browser's page zoom for those inside the frame and reports `pinch` and `pinch-end`. Your page may ignore them; nothing else happens. - The game does not check the parent's origin; it only checks that a message came from its parent window. It also posts with `*`, so do not rely on these messages for anything secret. - Outside a frame, `exitToShell()` calls `history.back()` when there is history, and the template's `App.tsx` shows the "Play" button. ## Known issues - **`THREE.Clock: This module has been deprecated`** is logged twice on load. Harmless. - **Sound starts on the first tap.** Browsers hold audio back until the page gets a user gesture; the engine starts the mixer and the race loops on the first tap, click or key ([Audio](https://docs.moonspell.app/engine/audio.md)). - **A stale or symlinked `node_modules` breaks the web bundle.** An install that is missing `react-native-web` fails with `Unable to resolve "react-native-web/dist/exports/Pressable"`. A `node_modules` reached through a symlink fails too, because Metro resolves a package's imports from its real path. Run a real `npm install` in the game's folder. - **`public/` goes into every Expo export.** After `npm run web`, `public/canvaskit.wasm` stays on disk and a later `expo export --platform ios` or `android` copies it into that output as well. Harmless for a game of your own; delete `public/canvaskit.wasm` before a phone export if the 8 MB matters. ## See also - [Platform differences](https://docs.moonspell.app/platforms/differences.md): every difference between web, iOS and Android in one table. - [Platform layer](https://docs.moonspell.app/engine/platform-layer.md): the files that make one source run on three platforms. - [iOS](https://docs.moonspell.app/platforms/ios.md) and [Android](https://docs.moonspell.app/platforms/android.md): development builds of the same game. - [Quickstart](https://docs.moonspell.app/start/quickstart.md): from clone to the default game in a browser. --- # iOS Source: https://docs.moonspell.app/platforms/ios > How to run a Moonspell engine game as a normal Expo development build on an iOS simulator or iPhone, what app.json needs, release builds, and what differs from playing inside the Moonspell app. > **Not built on the authors' machine.** `npm run ios` (prebuild, CocoaPods, Xcode, a simulator or an iPhone) was never run for this page. What was run: `npx expo config` (the config is valid and the `react-native-webgpu` plugin resolves), `npx expo prebuild --no-install --clean` in a throwaway copy of the template (it succeeded), and `npx expo export --platform ios` (the JavaScript bundle builds). It is not known from our own runs whether the app launches, whether WebGPU draws in the simulator, or how fast it runs. The steps below are Expo's standard ones, checked against Expo SDK 58's docs and the template's config. On iOS your game is an ordinary Expo app: `expo run:ios` generates the native project, builds it with Xcode, installs it and loads the game's JavaScript from Metro. The engine needs nothing from the Moonspell app to run there ([What is and is not open](https://docs.moonspell.app/start/open-source.md)). ## Prerequisites - A Mac with Xcode and an iOS simulator, or an iPhone with a cable. `expo run:ios` only runs on a Mac with Xcode installed. - CocoaPods (prebuild installs the pods). - Node and npm, and a real `npm install` in the game's folder. - For an iPhone: an Apple account in Xcode with a signing team, and a bundle identifier of your own (below). On iOS 16 and later the phone needs Developer Mode on ([Expo: iOS developer mode](https://docs.expo.dev/guides/ios-developer-mode/)). ## Run it ```bash npm install npm run ios ``` `npm run ios` is `expo run:ios`. The first run generates `ios/` (Expo's prebuild), installs the pods, compiles a debug build, installs it on a simulator and starts Metro. Later runs skip prebuild while `ios/` exists. Arguments after `--` go to `expo run:ios`: ```bash npm run ios -- --device ``` `--device` lets you pick a connected iPhone or a simulator. The template does not include `expo-dev-client`, so this is a plain debug build that loads from Metro, without the development launcher screen. Add `expo-dev-client` only if you want that launcher. React Native 0.88 always runs the New Architecture; there is no flag to set. ## What `app.json` needs The template's `app.json`: ```json { "expo": { "name": "Sunset Sprint", "slug": "sunset-sprint", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "dark", "backgroundColor": "#07070C", "ios": { "bundleIdentifier": "com.rofi.crescentmoon.game" }, "android": { "package": "com.rofi.crescentmoon.game" }, "plugins": ["react-native-webgpu"], "extra": { "genux": { "backgroundColor": "#07070C" } } } } ``` | Key | Why | | --- | --- | | `plugins: ["react-native-webgpu"]` | react-native-webgpu's config plugin. On iOS it turns Metal API validation off in the Xcode scheme's Run action (the prebuild check showed `enableGPUValidationMode = "1"`). The library's docs say validation can raise false positives in Dawn that crash the app on simulators. Keep it | | `ios.bundleIdentifier` | Change `com.rofi.crescentmoon.game` to an id of your own before you build for a phone: Xcode makes the provisioning profile for this id | | `orientation: "portrait"` | Locks the app to portrait, the shape the engine's HUDs and controls are laid out for | | `name` | The app's name on the home screen | | `extra.genux` | Not read by the engine. Harmless in your build | ### Minimum iOS version The template does not set a deployment target, so prebuild uses Expo SDK 58's default: iOS 16.4 in the prebuild check. That is above what the engine's native libraries ask for (Skia's podspec says iOS 14.0; react-native-webgpu, MMKV and Reanimated follow React Native's minimum). ### Deep links The template sets no `scheme`, so your build opens no links. `useLaunchRoomId()` falls back to React Native's `Linking`, so if you add an Expo `scheme` to `app.json`, a link with `?room=CODE` that opens the app drops the player into that room ([Net](https://docs.moonspell.app/engine/net.md)). Not tested. ## On an iPhone ```bash npm run ios -- --device ``` Pick the connected phone. Set `ios.bundleIdentifier` to your own id first, so Xcode can make a provisioning profile for it, and turn on Developer Mode on the phone. For signing and local builds in general, follow [Expo: local app development](https://docs.expo.dev/guides/local-app-development/); this page does not repeat it. react-native-webgpu's docs say the Metal validation false positives do not happen on physical devices, and recommend testing on hardware for real performance. ## Release builds At a pointer level only; none of this was run for the engine: - Locally: `npx expo run:ios --configuration Release` makes a release build, unsigned. Signing it for the App Store is covered by [Expo: local app production](https://docs.expo.dev/guides/local-app-production/). You can also open `ios/*.xcworkspace` in Xcode and archive from there. - In the cloud: EAS Build (`eas build --platform ios`), which handles signing credentials ([Expo: EAS Build](https://docs.expo.dev/build/introduction/)). Expo's development-build profiles expect `expo-dev-client`, which the template does not include. ## If reloads crash A development build runs the upstream versions of Skia and react-native-audio-api. The Moonspell app carries its own fixes for two teardown problems in them (Skia releasing the Metal device too early, and react-native-audio-api calling a nil render block). With one game per app they should matter only when the JavaScript reloads, for example on save in development. If repeated reloads crash your build, suspect these first. Untested. ## Different from the Moonspell app | | Inside the Moonspell app | Your Expo build | | --- | --- | --- | | How the game loads | The app loads the game's export into its own runtime | Your app is the game; Metro serves it in development | | Exit button | Back to the app's games | `exitToShell()` does nothing; the template's `App.tsx` unmounts the game and shows its "Play" button | | Paused off screen | Yes, while the app's feed keeps the game loaded but not shown | Never: the game is always active | | `getLaunchUrl()`, `getShareUrl()`, `roomInviteUrl()` | The app passes the links in | `null` | | Live updates, App Clip, feed video | Yes | No | | Game backend | The game's own Worker | `GAME_BACKEND_URL` is `null` in the template: leaderboards, presence and rooms stay on this device until you deploy the backend and set it | On the phones the shell is the optional native module `GenUXExpoModule`; in your build `requireOptionalNativeModule` returns `null` and the engine runs on its own ([Platform layer](https://docs.moonspell.app/engine/platform-layer.md)). All the differences between platforms are on [Platform differences](https://docs.moonspell.app/platforms/differences.md). ## See also - [Android](https://docs.moonspell.app/platforms/android.md): the same for Android. - [Web](https://docs.moonspell.app/platforms/web.md): the fastest way to work on a game. - [Quickstart](https://docs.moonspell.app/start/quickstart.md) --- # Android Source: https://docs.moonspell.app/platforms/android > How to run a Moonspell engine game as a normal Expo development build on an Android emulator or phone, why the react-native-webgpu plugin and minSdk 26 are required, the package name, release builds, and what differs from the Moonspell app. > **Not built on the authors' machine.** `npm run android` (prebuild, Gradle, an emulator or a phone) was never run for this page. What was run: `npx expo config` (valid, the `react-native-webgpu` plugin resolves) and `npx expo prebuild --no-install --clean` in a throwaway copy of the template, which succeeded and gave the values below. It is not known from our own runs whether the app launches, whether WebGPU draws on an emulator, or how fast it runs. The steps are Expo's standard ones, checked against Expo SDK 58's docs and the template's config. On Android your game is an ordinary Expo app: `expo run:android` generates the native project, builds it with Gradle, installs it and loads the game's JavaScript from Metro. The engine needs nothing from the Moonspell app to run there ([What is and is not open](https://docs.moonspell.app/start/open-source.md)). ## Prerequisites - Android Studio with the Android SDK, and an emulator or a phone with USB debugging on. - Java (a JDK). `expo run:android` needs Android Studio and Java installed and configured. - Node and npm, and a real `npm install` in the game's folder. ## Run it ```bash npm install npm run android ``` `npm run android` is `expo run:android`. The first run generates `android/` (Expo's prebuild), compiles a debug build, installs it on an emulator or a connected phone and starts Metro. Later runs skip prebuild while `android/` exists. Arguments after `--` go to `expo run:android`: ```bash npm run android -- --device ``` `--device` lets you pick a connected phone or an emulator. The template does not include `expo-dev-client`, so this is a plain debug build that loads from Metro, without the development launcher screen. ## What `app.json` needs The Android part of the template's `app.json`: ```json { "expo": { "orientation": "portrait", "android": { "package": "com.rofi.crescentmoon.game" }, "plugins": ["react-native-webgpu"] } } ``` | Key | Why | | --- | --- | | `plugins: ["react-native-webgpu"]` | Required on Android. react-native-webgpu's native code needs API level 26 (`androidMinSdkVersion` in its `package.json`), and its `build.gradle` does not go lower for an app with a lower `minSdkVersion`, so such an app fails with a manifest-merger error. Expo's default is 24. The config plugin raises the app's `minSdkVersion` to 26 | | `android.package` | Change `com.rofi.crescentmoon.game` to an id of your own. It becomes the `namespace` and `applicationId` | | `orientation: "portrait"` | Becomes `screenOrientation="portrait"` on the activity | What the prebuild check generated: | Setting | Value | | --- | --- | | `android.minSdkVersion` | 26 (Android 8.0) | | `newArchEnabled` | `true` | | `hermesEnabled` | `true` | | `namespace`, `applicationId` | `com.rofi.crescentmoon.game` | | `screenOrientation` | `portrait` | The template sets no `scheme`, so your build opens no deep links. If you add one, `useLaunchRoomId()` reads `?room=CODE` from the link through React Native's `Linking` ([Net](https://docs.moonspell.app/engine/net.md)). Not tested. ## Emulators and phones On Android, react-native-webgpu runs Dawn on Vulkan. Its docs warn that Android emulators may fall back to a software adapter: slow, and some features may be missing. Test performance on a real phone. ## Release builds At a pointer level only; none of this was run for the engine: - Locally: `npx expo run:android --variant release` makes a release build, unsigned. Signing it for the Play Store is covered by [Expo: local app production](https://docs.expo.dev/guides/local-app-production/). - In the cloud: EAS Build (`eas build --platform android`), which handles the keystore ([Expo: EAS Build](https://docs.expo.dev/build/introduction/)). Expo's development-build profiles expect `expo-dev-client`, which the template does not include. ## Different from the Moonspell app | | Inside the Moonspell app | Your Expo build | | --- | --- | --- | | How the game loads | The app loads the game's Android export into its own runtime | Your app is the game; Metro serves it in development | | Exit button | Back to the app's games | `exitToShell()` does nothing; the template's `App.tsx` unmounts the game and shows its "Play" button | | Paused off screen | Yes, while the app's feed keeps the game loaded but not shown | Never: the game is always active | | `getLaunchUrl()`, `getShareUrl()`, `roomInviteUrl()` | The app passes the links in | `null` | | The app's feed and share links | Yes | No | | Game backend | The game's own Worker | `GAME_BACKEND_URL` is `null` in the template: leaderboards, presence and rooms stay on this device until you deploy the backend and set it | Icons on Android draw the Material Symbol twin of each SF Symbol, and Skia text uses the `sans-serif` family where the engine names a system font. These and the other differences are on [Platform differences](https://docs.moonspell.app/platforms/differences.md). ## See also - [iOS](https://docs.moonspell.app/platforms/ios.md): the same for iOS. - [Web](https://docs.moonspell.app/platforms/web.md): the fastest way to work on a game. - [Platform layer](https://docs.moonspell.app/engine/platform-layer.md): how the engine runs with no Moonspell app around it. - [Quickstart](https://docs.moonspell.app/start/quickstart.md) --- # Platform differences Source: https://docs.moonspell.app/platforms/differences > What differs between web, iOS and Android for a Moonspell engine game (rendering, input, audio, haptics, storage, fonts, safe areas, orientation, links, performance), where the engine handles each difference, and what the Moonspell app's shell adds when it is there. One game source plays in a browser, on iOS and on Android. This page lists every difference between the three that a game author may notice, each with the engine file that handles it. Use it when something looks or behaves differently on one platform, and before you reach for a platform check in your game. ## The rule Platform differences are handled in the engine, never in a game. - Platform-specific code lives in `src/engine/`: a `*.web.ts` file next to the default file, or a `Platform.OS` check inside an engine module. - Game code in `src/game/` uses engine APIs that behave the same everywhere. It has no `Platform.OS` checks and no `*.web.ts` files. - A module that only exists on phones (haptics, sensors) is used so the game still plays without it. If you find a difference that a game would have to work around, fix it in the engine. How the files are picked per platform is on [Platform layer](https://docs.moonspell.app/engine/platform-layer.md). ## Where the engine branches The engine's whole platform layer, found by searching `src/engine` for `*.web.ts` files and `Platform.OS` / `Platform.select`: | File | Branch | | --- | --- | | `index.web.ts` (template root) | The browser entry: `bootWeb()` instead of `registerRootComponent()` | | `src/engine/web/boot.ts`, `web/systemFont.ts` | Loaded on the web only: WebGPU check, CanvasKit, Skia font fallback | | `src/engine/shellModule.web.ts` | The shell is the framing page, over `postMessage` | | `src/engine/input/keyboard.ts` | `Platform.OS !== 'web'`: no key listener on the phones | | `src/engine/models/assetBytes.ts` | `Platform.OS === 'web'`: fetch the asset's URL; phones read the file | | `src/engine/ui/Icon.tsx` | `Platform.OS === 'ios'`: SF Symbol; otherwise its Material Symbol twin | | `src/engine/ui/motion.ts` | `Platform.OS === 'web'`: built-in Reanimated entrances instead of a `Keyframe` | | `src/engine/hud/Gauge.tsx`, `modules/racing/RaceHud.tsx`, `2d/GameCanvas2D.tsx` | `Platform.select`: a named system font family on iOS, `sans-serif` elsewhere | Everything else differs only because a library has a different build per platform (react-native-web, CanvasKit, MMKV's web build, and so on). ## Differences by area | Area | Web | iOS | Android | Handled in | | --- | --- | --- | --- | --- | | 3D rendering | three.js `WebGPURenderer` on the browser's `navigator.gpu` | the same renderer on react-native-webgpu: Dawn on Metal | the same renderer on react-native-webgpu: Dawn on Vulkan | `render/EngineCanvas.tsx`; no WebGL path anywhere | | No WebGPU (no `navigator.gpu`, or no adapter) | The page shows "This browser can't run this game yet. Try the latest Chrome, Edge or Safari." | Not applicable (react-native-webgpu ships Dawn) | Not applicable | `web/boot.ts` | | Shadows | VSM | VSM | VSM | `Environment` sets VSM everywhere: the other filters lose the GPU device on react-native-webgpu's Metal backend ([Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md)) | | Skia (HUD, 2D layer) | CanvasKit, `canvaskit.wasm` served next to the page | Skia's native build | Skia's native build | `web/boot.ts` loads CanvasKit before the game | | Views | react-native-web (DOM) | React Native | React Native | Library builds | | Touch | react-native-gesture-handler's web build; a mouse acts as one finger | Multi-touch | Multi-touch | `input/` | | Keyboard | `TouchControls`, `Joystick` and `CharacterControls` also read keys ([Input](https://docs.moonspell.app/engine/input.md)) | No listener | No listener | `input/keyboard.ts`, `input/keyMap.ts` | | Two-finger pinch | In a frame only: read as closing the game and reported to the framing page | Belongs to the game | Belongs to the game | `shellModule.web.ts` | | Mixer audio | react-native-audio-api's web build on the browser's `AudioContext`. Browsers hold audio until a user gesture; the mixer resumes on the first tap, click or key | react-native-audio-api | react-native-audio-api | `audio/mixer.ts` | | `RaceAudio`, 2D one-shots | `expo-audio`'s web build (HTML audio elements) | `expo-audio`, set to play with the silent switch on and mix with other apps | `expo-audio`, same settings | `audio/RaceAudio.ts`, `2d/sound.ts` (`setAudioModeAsync({ playsInSilentMode: true, interruptionMode: 'mixWithOthers' })`) | | Haptics | `expo-haptics`' web build: the Vibration API where the browser has one, a hidden switch control on iOS Safari, nothing on a desktop | `expo-haptics` | `expo-haptics` | `2d/haptics.ts`, `ui/PressScale.tsx`, `feel/FeelOverlay.tsx`: failures are caught, so a device without haptics is never an error | | On-device data | MMKV's web build on `localStorage`, so it is per browser origin | MMKV | MMKV | `net/storage.ts`; an in-memory store if MMKV cannot load | | Shared player store | `crescent.player` is shared by every game served from the same origin | shared by every engine game in the same app (in the Moonspell app, all of them) | same as iOS | `net/storage.ts`; each game also gets `crescent.game.<GAME_ID>` ([Saves](https://docs.moonspell.app/engine/saves.md)) | | Asset bytes (`.glb`, audio) | `fetch()` of the asset's URL | `expo-asset` then `expo-file-system` | same as iOS | `models/assetBytes.ts` | | Fonts loaded with `useGameFonts` | `expo-font` | `expo-font` | `expo-font` | `ui/useGameFonts.ts` | | Skia `matchFont()` | Always the engine's Nunito (CanvasKit has no system fonts) | The system family the engine names (`Helvetica Neue`, `Avenir Next`) | `sans-serif` | `web/systemFont.ts`, the `Platform.select` lines above | | Icons | Material Symbol twin | SF Symbol | Material Symbol twin | `ui/Icon.tsx`, `ui/androidSymbols.ts` ([HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)) | | Entrance animations | Built-in Reanimated entrances | `Keyframe` | `Keyframe` | `ui/motion.ts` | | Safe areas | `env(safe-area-inset-*)`, which is 0 unless the page sets `viewport-fit=cover`; the HUD's top row sits 14 px below the safe top | Insets from the device, read once at launch | Insets from the device, read once at launch | `core/insets.ts` / `core/insets.web.ts` (`getSafeInsets`, `useSafeInsets`, `hudTopInset`), used by the 2D layer, the puzzle screens and the HUD chrome; the HUD's top row stays at 54 points on phones | | Status bar | No effect | Hidden by the template's `App.tsx` | Hidden by the template's `App.tsx` | `src/App.tsx` (`expo-status-bar`) | | Orientation | Not locked; the page fills the window. A size change resizes the 3D renderer and camera in place | Portrait (`app.json`) | Portrait (`screenOrientation="portrait"`) | `app.json`, `render/EngineCanvas.tsx` | | Launch and share links | `?launch=` and `?share=` on the page URL; `?room=` through `Linking` | From the Moonspell app; `null` in your own build | same as iOS | `net/launch.ts` (table below) | | JavaScript engine | The browser's | Hermes | Hermes | Expo's defaults (`hermesEnabled=true` in the Android prebuild) | | Canvas resolution | Layout size times `PixelRatio.get()` (the window's device pixel ratio) | Layout size times the screen scale | Layout size times the screen density | `render/EngineCanvas.tsx` | ## Performance notes Nothing here was measured on phones; these are the facts we have. - Web download: in one export of the default game, the JavaScript bundle was 5.4 MB and `canvaskit.wasm` about 8 MB, 16 MB for all of `dist/`. - iOS bundle: `expo export --platform ios` gave a Hermes bytecode bundle of 8.3 MB for the same game. - The 3D canvas draws at full device pixel ratio. Post-processing has a `resolutionScale` for a cheaper scene pass ([Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md)). - react-native-webgpu's docs warn that Android emulators may use a software adapter (slow, some features missing) and recommend testing on physical devices. They also say Metal API validation on the iOS Simulator can cause false-positive crashes, which the `react-native-webgpu` config plugin turns off ([iOS](https://docs.moonspell.app/platforms/ios.md)). ## What the Moonspell app's shell adds The shell is the app around the game. Inside the Moonspell app it is the optional native module `GenUXExpoModule`; in a browser, the page that frames the game. `src/engine/shell.ts` and `src/engine/net/launch.ts` read it through optional chaining, so a missing shell, or a missing field, is never an error. | Engine API | Moonspell app | Your Expo build on a phone (no shell) | A browser tab, not framed | A browser, framed by your page | | --- | --- | --- | --- | --- | | `exitToShell()` | Back to the app's games | Does nothing | `history.back()` when there is history | Posts `exit` to the parent | | `onShellClosed(listener)` | Fires when the app closes the game | Never fires | Never fires | Fires on a `closed` message | | `isShellActive()`, `useShellActive()` | `false` while the app's feed keeps the game off screen | Always `true` | `true` unless the URL has `?active=0` | Follows `active` messages; starts from `?active=0` | | `onShellActive(listener)` | Fires now, then on every change | Fires once with `true` | Fires once with the starting state | Fires now, then on every change | | `getLaunchUrl()` | The link the game was opened with | `null` | The `?launch=` parameter | The `?launch=` parameter | | `getShareUrl()`, `roomInviteUrl()` | The game's public link | `null` | From `?share=`, else `null` | From `?share=`, else `null` | | `useLaunchRoomId()` | Room from the launch link | Falls back to `Linking` (needs a `scheme` in `app.json`) | Falls back to `Linking`, so `?room=CODE` works | Same as not framed | In every case the template's `App.tsx` does the same thing on exit or close: it unmounts the game, which stops its sound, simulation and rendering, and shows a "Play" button that starts it again. While the game is not active, the engine pauses on its own: `EngineCanvas` stops its frame loop, `GameCanvas2D` stops stepping, and the audio mixer and `RaceAudio` go quiet. Only the Moonspell app has its feed, share pages, App Clip and live updates. How it loads games is not part of the engine ([What is and is not open](https://docs.moonspell.app/start/open-source.md), [Builds and versions](https://docs.moonspell.app/moonspell/builds.md)). ## See also - [Web](https://docs.moonspell.app/platforms/web.md), [iOS](https://docs.moonspell.app/platforms/ios.md), [Android](https://docs.moonspell.app/platforms/android.md) - [Platform layer](https://docs.moonspell.app/engine/platform-layer.md) - [Net](https://docs.moonspell.app/engine/net.md) for launch links and rooms --- # Architecture Source: https://docs.moonspell.app/moonspell/architecture > The whole hosted Moonspell system on one page, from the app and the server to the sandbox, storage, the edge Worker and each game's own backend. This page explains how the hosted Moonspell product fits together. Players chat with **Nova** in the Moonspell app. Nova starts a **worker run**: Claude Code, headless, in a throwaway sandbox, editing that game's own repo with the open-source engine. The run exports the game, the server stores it as a new version, and the app plays it. Most of this system is public in this repo: the server, Nova, the run workflow, the edge Worker, the contracts and the feature specs. The native iOS and Android app and the bundling CLI are private. [What is open and what is private](https://docs.moonspell.app/start/open-source.md) draws that line. ## The system on one page ```text Moonspell apps Server (Next.js on Vercel) Sandbox (Daytona), one per run -------------- -------------------------- ------------------------------ iOS, Android (private) chat Nova (agent/) Claude Code, headless web (src/web, src/app) -------> make_game / change_game in a clone of the game's repo | | | | v | | game_runs row (queued) | | | | | v | | game-run workflow (src/workflows/) ------> creates the box, starts run.sh | | polls every 15 s <------------------ exit code, progress line | | uploads the exports <--------------- one export per platform | | pushes the commit ---> GitHub (one private repo per game) | v | Postgres (Neon): users, games, builds, runs, credits | Blob storage (Vercel Blob): every version's files, covers | | live progress (WebSocket) Edge Worker (edge/, Cloudflare) +<--------------------------------- RunChannel Durable Object per run <---- relay in the box posts events | proxies every other path to the server | | play a game Game backend (optional, one Cloudflare Worker per game) +---------------------------------> leaderboards, presence, rooms, saves ``` ## The parts | Part | Where | Runs on | What it does | |---|---|---|---| | Moonspell app | private (iOS, Android); `src/web/` and `src/app/(app)/` (web) | phones, browsers | The games feed, Nova's chat, the player, sharing. Downloads a version's export for its platform and runs it. | | Server | repo root: `src/`, `agent/`, `db/` | Vercel | The app API (`src/app/api/app/`), Nova (mounted at `/eve/v1/*`), the run and cover workflows (`src/workflows/`), the shared-game page `/g/<buildId>`, the web app. | | Sandbox | created by `src/lib/daytona.ts` | a sandbox provider (Daytona) | One throwaway box per run with Claude Code, git, Node and a copy of the game template. Deleted when the run ends. | | Edge | `edge/` | Cloudflare Workers | The public host. Proxies the server, streams live run progress through one Durable Object per run, and meters the builder's Claude calls when credits are on. | | Game template | `templates/crescent-game/` | copied into every game repo | The engine, the starter game and the instructions and skills the worker reads. See [Engine overview](https://docs.moonspell.app/engine/overview.md). | | Game backend | the template's `realtime-backend` skill | each game's own Cloudflare Worker | Live leaderboards, "N playing now", rooms and synced saves. See [Backend overview](https://docs.moonspell.app/backend/overview.md). | `next.config.ts` wraps the Next.js app with `withEve(withWorkflow(...))`, so Nova and the workflows deploy with the server as one project. ## One request, start to finish 1. **Chat.** The player writes to Nova. Nova asks a question or two at most, then calls `make_game` (a new game) or `change_game` (an existing one) with a complete brief. 2. **Queue.** The tool inserts a `queued` row in `game_runs` and asks the server to start the run (`POST /api/internal/runs`). A game has at most one active run at a time on each branch. 3. **Build.** The workflow creates a sandbox, writes the run's files into it and starts the run script. Claude Code builds in checkpoints, so a run always has a playable version to fall back on. See [Nova and the worker](https://docs.moonspell.app/moonspell/nova-and-the-worker.md). 4. **Watch.** A relay inside the box posts progress lines to the edge, and the app shows them on a building card. See [Live progress](https://docs.moonspell.app/moonspell/realtime.md). 5. **Store.** The workflow uploads each platform's export to blob storage, records the version in `game_builds` and pushes the commit to the game's repo. See [Builds and versions](https://docs.moonspell.app/moonspell/builds.md). 6. **Tell Nova.** The workflow posts a hidden `[Build ready]` or `[Build failed]` message into the chat. Nova answers with a card that plays the new version. 7. **Share.** Every version has its own link, `/g/<buildId>`. See [Sharing](https://docs.moonspell.app/moonspell/sharing.md). ## Server code map | Area | Files | |---|---| | Nova | `agent/agent.ts`, `agent/instructions.ts`, `agent/instructions/status.ts`, `agent/tools/`, `agent/lib/`, `agent/channels/app.ts`, `agent/hooks/meter.ts` | | Runs | `src/workflows/game-run.ts` (the workflow), `src/workflows/game-run-steps.ts` (its steps) | | Sandbox and run script | `src/lib/daytona.ts` | | Games, versions, runs | `src/lib/games.ts` | | Game repos | `src/lib/github.ts` (a GitHub App creates one private repo per game and mints single-repo tokens) | | Live progress | `src/lib/realtime.ts` (signs `crt1` tokens, publishes events to the edge) | | Game backends | `src/lib/cloudflare-gateway.ts` (signs tokens a Cloudflare gateway accepts for one game's resources) | | Accounts | `src/lib/app-session.ts` (anonymous accounts, bearer sessions), `src/lib/sign-in.ts` (Google and Apple) | | Which export each app gets | `src/lib/client.ts` | | Sharing | `src/lib/app-clip.ts`, `src/app/g/[buildId]/`, `src/app/.well-known/` | | Community builds | `src/lib/community.ts`, `community-merge.ts`, `community-cancel.ts`, `judge.ts`, `badges.ts` | | Credits | `src/lib/credit-prices.ts`, `src/lib/credits.ts`, `src/lib/usage.ts` | | Crash fixes | `src/lib/heal.ts` | | Generated art, sounds and models | `src/lib/higgsfield.ts`, `src/lib/models.ts`, `src/lib/sprite-sheet.ts`, `src/workflows/game-cover.ts` | | Server-to-server calls | `src/lib/internal.ts` (`INTERNAL_API_SECRET`) | Two base URLs matter (`src/lib/users.ts`): `PUBLIC_BASE_URL` is the public host for everything players see, and `CRESCENT_APP_URL` is where server code reaches the Next.js app directly, never through the edge. ## Accounts Every app starts on an anonymous account (`POST /api/app/account`), so playing needs nothing. Making or changing a game needs a registered account: Google on every app, Apple on iOS (`POST /api/app/google`, `POST /api/app/apple`). Signing in links the anonymous account, or switches to the account that identity already has. `CRESCENT_REQUIRE_SIGN_IN` turns the rule on or off; unset, it is on once `GOOGLE_WEB_CLIENT_ID` is set (`signInRequired()` in `src/lib/sign-in.ts`). ## The API The app API lives under `/api/app/*`. It answers JSON with `Cache-Control: no-store`, and most routes need `Authorization: Bearer <session token>`. | Routes | What they cover | |---|---| | `/api/app/account`, `/me`, `/google`, `/apple`, `/config` | Accounts and sign-in | | `/api/app/games`, `/games/:id`, `/feed`, `/previews/:buildId` | The player's games, the public feed, one shared version | | `/api/app/games/:id/play`, `/games/:id/crash` | Play time, crash reports | | `/api/app/games/:id/merge`, `/games/:id/community`, `/contributions/:id/cancel`, `/contributions/:id/badges` | [Community builds](https://docs.moonspell.app/moonspell/community-builds.md) | | `/api/app/runs/:id/assets`, `/credits`, `/usage` | [Credits](https://docs.moonspell.app/moonspell/credits.md) | | `/api/app/chats`, `/files` | Chat list, picture uploads | | `/eve/v1/app/chats/:chatId/messages`, `/eve/v1/app/sessions/:sessionId/stream`, `/eve/v1/app/chats/:chatId/cancel`, `/eve/v1/app/chats/:chatId/build` | Nova's chat channel (`agent/channels/app.ts`); the stream is NDJSON | | `/api/internal/runs`, `/api/internal/meter/*`, `/eve/v1/app/internal/deliver` | Server-to-server, with `INTERNAL_API_SECRET` | Every app sends `X-Crescent-Client: ios`, `android` or `web`, and the server hands each one its own export of a version. See [Three apps, one product](https://docs.moonspell.app/moonspell/three-apps.md). ## Data Postgres, with the schema in `db/migrations/` (applied by `npm run db:migrate`, which records each file in `schema_migrations`). | Table | Holds | |---|---| | `users` | A player: anonymous until a Google or Apple identity is linked; credit balance | | `games` | One game, one repo: title, slug, repo name, cover | | `app_chats` | One conversation with Nova, tied to a game; `contributing` when it is about someone else's game | | `game_builds` | One playable version: version number, commit, one manifest URL per platform, notes, design document. Its random id is the share link. Drafts carry a branch and a draft number instead of a version | | `game_runs` | One worker run: request, status, progress line, result, error, the version it made | | `game_contributions`, `contribution_badges` | Accepted or declined community changes, and badges | | `credit_ledger` | Every credit change | | `game_plays`, `game_playtests` | Play time, and the results of scripted playtests | | `service_tokens` | Refreshed credentials for server-side services | A partial unique index on `game_runs` allows at most one queued or running run per game and branch: one on the game's public branch, and one on each player's draft branch. ## Storage | What | Where | |---|---| | Accounts, games, chats, runs, versions, credits | Postgres (`DATABASE_URL`) | | Every version's exported files, covers, chat pictures | Vercel Blob (`BLOB_READ_WRITE_TOKEN`), under `games/<gameId>/...` and `chat/<userId>/...` | | Game source | GitHub, one private repo per game in `GAMES_GITHUB_ORG` | | Nova's conversations | eve's durable sessions | | Live run events | The edge's `RunChannel` Durable Object for that run | | Game data (scores, rooms, saves) | The game's own Cloudflare Worker, when it has a backend | --- # Nova and the worker Source: https://docs.moonspell.app/moonspell/nova-and-the-worker > How Nova, the chat agent in agent/, hands a brief to the worker run, how the worker builds in checkpoints so a run always lands a version, and how left-out extras come back as suggestions. Moonspell uses two agents, and each has one job: - **Nova** (`agent/`, an eve agent on the server) talks to the player, decides what to build, writes the brief and starts runs. She has three tools and no sandbox, shell or file access. She never writes code. - **The worker** (Claude Code, headless, in a sandbox per run) builds. It never sees the chat. Nova's brief is its whole task, and the game repo's `CLAUDE.md` and skills are what it knows. Between them sits the **game-run workflow** (`src/workflows/game-run.ts`). It owns the sandbox, the credentials, the uploads and the push. ## Nova | File | Role | |---|---| | `agent/agent.ts` | `defineAgent` on the model in `agent/model.ts`, with eve's default tools turned off | | `agent/instructions.ts` | Who Nova is, how she talks, what Moonspell can build, how she uses her tools, how she answers system messages | | `agent/instructions/status.ts` | A dynamic instruction rebuilt at the start of every turn: the player's games, their latest versions, any build in progress or recent failure, and whether online features are on | | `agent/tools/make_game.ts`, `change_game.ts`, `show_game.ts` | Her three tools | | `agent/lib/runs.ts` | Resolving which game a tool acts on, and starting a run | | `agent/lib/caller.ts` | The player behind a turn, and the chat it belongs to | | `agent/channels/app.ts` | The chat routes the apps call | | `agent/hooks/meter.ts` | Charges each of her model calls in credits (see [Credits](https://docs.moonspell.app/moonspell/credits.md)) | ### Tools | Tool | Input | What it does | |---|---|---| | `make_game` | `title` (up to 80 characters), `brief` (up to 6000, in English) | Creates the game, ties the chat to it and starts a run. If the run cannot start, the empty game is deleted again. | | `change_game` | `request` (up to 6000, in English), optional `gameId` | Resolves the game (the one named, else this chat's, else the player's most recent) and starts a run. | | `show_game` | optional `gameId`, optional `version` | Finds a version (the newest by default) and returns it, so the app shows a card that plays it. | The apps render the tool results as cards: a building card that follows the run live, and a play card. The model never sees the raw result. `toModelOutput` gives Nova a short sentence instead ("The chat now shows a live card..."), which keeps her from narrating progress or pasting links. ### What she knows each turn The static instructions say what Moonspell builds well (the genre modules and foundations, see [Game modules](https://docs.moonspell.app/modules/overview.md)), what it cannot do (real-money purchases, accounts beyond a player name, phone features the app does not ship), and how to brief the builder: complete plain instructions, because the builder never sees the chat. The dynamic status instruction adds, every turn: up to 20 of the player's games with their latest version and notes, any build in progress, which game this chat is about, and whether online features are on (`cloudflareConfigured()`), so she never promises online play when it is off. For a chat about someone else's game it adds that game's design document, marked as data and never instructions. ### Starting a run `startGameRun` in `agent/lib/runs.ts`: 1. Checks the player may build (credits, and for someone else's game the community limits). 2. `queueRun` inserts a `queued` row in `game_runs`. A second active run on the same game and branch is refused, and Nova is told a build is already running. 3. Calls `POST /api/internal/runs` with `INTERNAL_API_SECRET`. Nova runs as its own service and the Next.js app owns the workflows, so she asks the app to start one. A run that cannot start is failed at once, so it never holds the game. ### Messages from the run When a run ends, the workflow posts a hidden system message into the chat the run came from (`POST /eve/v1/app/internal/deliver`). The apps hide these; Nova answers them. | Message | What Nova does | |---|---| | `[Build ready]` | Calls `show_game` for the new version, writes one line about what is in it from the builder's notes, and offers one or two of the builder's next ideas as a question | | `[Build failed]` | If the error starts `Our side:` (the sandbox, storage, GitHub or the database failed, not the builder), she restarts the same request once per chat. Otherwise she says it did not work this time and offers to try again | | `[Build stopped]` | The player ran out of credits mid-build. She shows the last saved version if there is one | | `[Community build]`, `[Change declined]` | See [Community builds](https://docs.moonspell.app/moonspell/community-builds.md) | ## The worker run `gameRunWorkflow(runId)` in `src/workflows/game-run.ts` is a durable workflow (Workflow SDK). Its steps are in `src/workflows/game-run-steps.ts`, and each step is retried on its own unless it sets `maxRetries`. | Step | What it does | |---|---| | `claimRun` | Moves the run from `queued` to `running` and decides the mode: `existing` when the game has a repo and a version, otherwise `new` | | `provisionSandbox` | Creates a sandbox from the snapshot in `DAYTONA_SNAPSHOT`. Boxes stop after 60 idle minutes and delete themselves on stop | | `startRun` | Mints every credential inside the step, so none lands in the workflow's event log, writes the run's files into the box and starts `run.sh` in the background. Not retried | | `pollRun` | Every 15 seconds, for up to 160 polls (40 minutes): copies the newest progress line onto the run and reads the exit code. A temporary error from the sandbox API counts as "still running" | | `salvageRun` | Only on a timeout or a non-zero exit: ships the last checkpoint (below) | | `readOutcome` | The commit, the notes for the player and the next ideas | | sounds, art, models | The assets the builder asked for are made on the server (below) | | `nextVersion`, `uploadExport`, `uploadAndroidExport`, `uploadWebExport`, `recordBuild` | Stores the new version. See [Builds and versions](https://docs.moonspell.app/moonspell/builds.md) | | `pushGame` | Creates the game's repo on its first version and pushes the commit with a write token only that one command sees | | `succeedRun` or `failRun` | The final status, published to the edge | | `withdrawBuild` | A version recorded but never pushed is deleted, so every version can be built upon | | `deliverOutcome` | The system message to Nova | | `destroySandbox` | Always, in a `finally` | A run that never finishes is also failed by `expireStaleRuns()` in `src/lib/games.ts`, 75 minutes after it was queued. ### Inside the sandbox The snapshot is built by `scripts/create-run-snapshot.ts`: Node 24, git, Claude Code, the private bundling CLI and the game template with its dependencies installed. `startRun` writes the run's files into `~/.crescent/` in the box, among them `run.sh`, `checkpoint.sh`, `ship-checkpoint.sh`, `helpers.cjs`, the request and the system prompt. Credentials go into a file with mode 0600 that `run.sh` reads once and deletes; they never appear on a command line. The script itself is `runScript()` in `src/lib/daytona.ts`. In order: 1. **Get the game.** A new game is a copy of the template, committed and named. An existing game is cloned with a read-only token, then `sync-engine` brings its engine up to date: files under `src/engine/`, `.claude/skills/`, `docs/`, `examples/`, plus `CLAUDE.md`, `AGENTS.md`, `index.web.ts` and the package files are replaced with the template's when the builder never changed them. Files the builder changed stay as they are. 2. **Drop the GitHub token.** The builder never needs GitHub. The workflow pushes later with a token of its own. 3. **Wake the builder.** Claude Code updates to its latest release. When game backends are configured, the run also gets a Cloudflare token scoped to this game's resources. 4. **The builder pass**: `claude -p` with a `/goal`, `--permission-mode bypassPermissions` and at most 160 turns, its stream-json output going to `progress.jsonl`. 5. **Export.** The game is exported with the private CLI. If that fails, the builder gets one fix pass (40 turns) with the error; if it still fails, the last checkpoint ships. 6. **Commit.** Anything left uncommitted is committed. If `HEAD` never moved, the script exits with code 3 ("The builder made no change"). The model defaults to `claude-opus-5-5` and can be changed with `CRESCENT_WORKER_MODEL`. ### What the builder is told The appended system prompt (`systemPrompt()` in `src/lib/daytona.ts`) gives the builder the player's request verbatim and tells it to: - work alone and make reasonable calls, since nobody will answer a question; - read `GDD.md` first and update it with every change (the template's **game-design-doc** skill); - follow the repo's `CLAUDE.md` and skills, starting with **crescent-game-change**; - check its work with `npx tsc --noEmit` and an export, since there is no device in the box; - commit with a player-facing message and never push; - never print or send environment variables, tokens or keys; - build the closest thing that works when part of the request is impossible; - build in checkpoints (below), with its own look and feel, and list the sounds, art and models it needs instead of making them. The template's side of this (the skills, the docs, the examples the builder reads) is described in [Build with AI](https://docs.moonspell.app/ai/overview.md). ## Checkpoints: a run always lands a version One rule shapes every run: **a version that plays beats a bigger one that does not.** The builder works in checkpoints: 1. Decide what matters most: the core of the request (for a new game, what the player does every few seconds) and the one or two things that make it theirs. Everything else is an extra, in order of importance. 2. Make the smallest version that plays start to finish (for a change, the most important part of it), write the notes, and save a checkpoint. 3. Add the extras one at a time, most important first, saving a checkpoint after each. 4. When an extra keeps breaking the game, go back to the last checkpoint (`git reset --hard`, `git clean -fd`) and leave it for next time. 5. Stop when the request is built, or when what is left is too big to finish. `bash ~/.crescent/checkpoint.sh "<message>"` is the save. It refuses without notes, typechecks, exports and commits, and records the commit with a copy of the notes. When a save fails, its output says why. Every failure path falls back to the last checkpoint: | What happened | What ships | |---|---| | The export fails after the builder's pass | One fix pass, then the last checkpoint if it still fails | | 40 minutes pass, or the script exits non-zero | `salvageRun` stops the builder and ships the last checkpoint (`ship-checkpoint.sh`) | | The player runs out of credits mid-build | The last checkpoint | | There is no checkpoint at all | The run fails and Nova offers to try again | | The builder changed nothing | The run fails with "The builder made no change." | `ship-checkpoint.sh` resets to the last checkpoint, restores its notes, drops the asset requests that belonged to discarded work and exports again. ## Notes and the left-out extras The builder writes two files: - `notes.txt`: one plain paragraph for the player, true to the last checkpoint. It becomes the version's notes on its card, on its share page and in Nova's message. - `next.txt`: up to three one-line ideas for what to build next, the extras it left out first. The workflow stores `next.txt` as `game_runs.next_ideas` and puts it in `[Build ready]`. Nova offers one or two of them to the player as what she could add next, so an extra that did not fit in this run becomes the next request. ## Sounds, art and 3D models The builder never downloads or synthesizes assets. For each one it needs, it writes a placeholder (a silent sound, a 1 by 1 image, a placeholder cube) that the engine already handles, and lists a description in `sounds.json`, `sprites.json` or `models.json`. After the pass, the workflow makes them on the server through the image and audio generation service (Higgsfield, `src/lib/higgsfield.ts`), writes each one over its placeholder, commits them and exports again. An asset that fails keeps its placeholder; if the export with the new assets fails, the new assets are dropped. The sandbox never gets a credential for that service. With credits on, the player approves the cost first (see [Credits](https://docs.moonspell.app/moonspell/credits.md)). When that service is configured, every new game also gets cover art, made by a separate workflow (`src/workflows/game-cover.ts`) started alongside its first run. ## Crash fixes When a player's own game crashes on their phone, the app reports it (`POST /api/app/games/:id/crash`) and `src/lib/heal.ts` starts a normal run with the error in its request. Guards: only the game's latest version, one automatic fix per version, none while a run is active, and at most two a day per game. Nova announces the fix like any build. ## Changing behaviour | To change | Edit | |---|---| | How Nova talks or what she promises | `agent/instructions.ts`, `agent/instructions/status.ts` | | Nova's tools | `agent/tools/`, `agent/lib/` (and each app's cards if a result shape changes) | | The worker's rules, goal and turn limits | `systemPrompt()` and `runScript()` in `src/lib/daytona.ts`, `MAX_TURNS` and `FIX_TURNS` in `src/workflows/game-run-steps.ts` | | What the worker knows about games | `templates/crescent-game/` (`CLAUDE.md`, `AGENTS.md`, skills, docs, engine). Runs only see it after the snapshot is rebuilt | --- # Builds and versions Source: https://docs.moonspell.app/moonspell/builds > What a Moonspell version is, how a run exports it once per platform, where the files are stored, and how each app gets its own export. A **version** is one playable state of a game: a commit in the game's repo plus that commit exported once for each platform. Every successful run makes one version. This page covers what a version holds, where it is stored and how each app finds its own copy. ## One source, three exports A game is written once and plays on iOS, Android and the web. A React Native bundle is platform-specific (Metro resolves `.web.ts`, `Platform.OS` and each native module's JavaScript per platform), so a run exports the same commit three times inside the sandbox: | Export | When | If it fails | |---|---|---| | iOS | Always; the builder's checkpoints check it | The run falls back to the last checkpoint (see [Nova and the worker](https://docs.moonspell.app/moonspell/nova-and-the-worker.md)) | | Android | After the iOS export passes | Best effort: the version ships without it | | Web | After the iOS export passes | Best effort: the version ships without it | The export step uses the private bundling CLI. The format it writes and how the private app loads it are not documented here; see [What is open and what is private](https://docs.moonspell.app/start/open-source.md). Outside Moonspell, a game is exported with plain Expo, for example `npm run export:web` in the template (see [Web](https://docs.moonspell.app/platforms/web.md)). ## Where a version lives | Piece | Where | |---|---| | Source | The commit, pushed to the game's private repo (`pushGame`) | | iOS files | Vercel Blob, `games/<gameId>/v<N>/` | | Android files | `games/<gameId>/v<N>/android/` | | Web files | `games/<gameId>/v<N>/web/` | | A player's draft of someone else's game | `games/<gameId>/drafts/<branch>/d<N>/` (see [Community builds](https://docs.moonspell.app/moonspell/community-builds.md)) | | The record | A `game_builds` row: game, version, commit, one manifest URL per platform (`manifest_url`, `android_manifest_url`, `web_manifest_url`), the builder's notes and the game's `GDD.md` | `uploadExportFiles` in `src/lib/export-upload.ts` uploads every file of an export first and its entry file last, so a version is never visible before the files it points to. ## The order of the steps From `src/workflows/game-run.ts`, after the builder's pass succeeds: 1. `readOutcome`: the commit, the notes and the next ideas. 2. Sounds, art and 3D models the builder asked for are made and committed, and the game is exported again with them. 3. `nextVersion`: the game's highest version plus one. 4. `uploadExport`, `uploadAndroidExport`, `uploadWebExport`: each platform's files. 5. `recordBuild`: the `game_builds` row. It is idempotent per game and version, so a retried step lands on the same row. 6. `pushGame`: the commit goes to the repo. 7. `succeedRun`: the run is marked succeeded, and a `build` event goes to the edge so the building card turns into a play card. If the push fails after the version was recorded, `withdrawBuild` deletes the row. A version only exists when its commit is in the repo, so the next run can always build on it. ## Which export each app gets Every app sends `X-Crescent-Client: ios`, `android` or `web`. Every response that carries a build goes through `clientJson` in `src/lib/client.ts`, which leaves one URL in `manifestUrl`: the asking app's own export, or an empty string when that version has none, which the app shows as "not on Android yet" or "not on the web yet". The other platforms' URLs never leave the server. ## Playing a version on the web Blob storage does not run scripts on its own pages, so the server serves the page that starts a web export: `GET /w/<buildId>` (`src/app/w/[buildId]/route.ts`, `gamePage()` in `src/lib/web-play.ts`). The bundle and assets still load from storage. A game is code a builder wrote from anyone's prompt, so it never runs on the web app's own origin, where the player's session lives. The web app frames `/w/<buildId>` from a separate **play origin**: `CRESCENT_PLAY_ORIGIN` when set, else the deployment's own domain when players reach the app through the edge, and locally the other loopback name (`localhost` and `127.0.0.1`). The game page moves itself to the play origin when opened anywhere else, and `frame-ancestors` only lets this server's pages frame it. The client half is `src/web/play.ts`. ## The starter game Every new account starts with **Sunset Sprint**, the template's own game, as version 1 (`src/lib/starter.ts`). Its exports are uploaded once with `npm run starter:upload`, which prints the URLs for `CRESCENT_STARTER_MANIFEST_URL`, `CRESCENT_STARTER_ANDROID_MANIFEST_URL` and `CRESCENT_STARTER_WEB_MANIFEST_URL`. The starter has no repo, so the first change Nova makes starts from the template and creates the game's repo with version 2. ## Scripts that export These root scripts export games, so they need the private CLI and stop with one sentence in a public clone (`scripts/require-private.mjs`): | Script | What it does | |---|---| | `npm run starter:upload` | Exports and uploads the starter game | | `npm run android:backfill -- [--game <id>] [--dry-run]` | Gives older games an Android export | | `npm run web:backfill -- [--game <id>] [--dry-run]` | Gives older games a web export | | `npm run snapshot:pack` | Packs the CLI into the sandbox snapshot | --- # Live progress Source: https://docs.moonspell.app/moonspell/realtime > How the edge Worker in edge/ streams a build's progress to the apps through one Durable Object per run, and what else the Worker does. While a worker run builds, the app shows its progress live on a building card: what stage it is at, what the builder says, which file it is editing. Those events travel through the **edge Worker** (`edge/`), which keeps one Durable Object per run. This page is about build progress in the hosted product. Realtime features inside games (leaderboards, presence, rooms) run on each game's own backend and are covered in [Backend overview](https://docs.moonspell.app/backend/overview.md). ## The path of an event ```text Sandbox Server (workflow) Edge Worker App relay reads progress.jsonl --+ claimRun: status running --+ progress, tool | succeedRun: build, status | +--- POST /rt/runs/:runId/events --+--> RunChannel Durable Object ---ws---> building card Bearer crt1 (publish) keeps the last 50 events wss /rt/runs/:runId?token=crt1 (subscribe) ``` ## Who publishes what - **The relay in the box.** `run.sh` starts `node helpers.cjs relay`, which reads new lines of `progress.jsonl` about once a second and posts them to the edge in batches of up to 100 events. It is best effort: a failed post is dropped and never slows the run. It turns lines into events: - a stage from the script ("Fetching your game", "Packaging the game") becomes `progress`; - the builder's own text becomes `progress`, one line, at most 280 characters; - a tool call becomes `tool`, with a player-friendly detail such as "Editing src/game/index.tsx", at most 200 characters; - the final result becomes `progress` "Checking the result". - **The workflow.** `status: running` when it claims the run, then `build` and `status: succeeded`, or `status: failed`, at the end. With credits on it also publishes an `assets` event when it asks the player to approve art and sounds. Each batch gets a fresh, short-lived publish token (`publishEvents()` in `src/lib/realtime.ts`, 5 second timeout, never throws). - **The 15-second poll.** `pollRun` copies the newest progress line into `game_runs.progress`. The app shows that when it has no live socket. ## Event shapes From `RunEvent` in `src/lib/realtime.ts`: ```ts { type: "progress", at, text } { type: "tool", at, name, detail } { type: "status", at, status } // "queued" | "running" | "succeeded" | "failed" { type: "build", at, buildId, version, manifestUrl, androidManifestUrl?, webManifestUrl?, draft? } { type: "assets", at, quote } ``` The edge only checks that `type` is a string. Clients ignore types and fields they do not know. ## The edge API ```text POST /rt/runs/:runId/events Authorization: Bearer <publish token> {"events": [ ... ]} GET /rt/runs/:runId?token=<subscribe token> (Upgrade: websocket) GET /rt/health {"ok": true, "realtime": <secret set>} ``` - **Publish**: 1 to 100 events per call, each an object whose `type` is a string of at most 64 characters and at most 4 KB as JSON. The answer is `{"ok": true, "accepted": n, "subscribers": n}`. Errors: 400 (not JSON, no `events` array, empty, no `type`), 413 (too many events or too large), 401 (bad token), 503 (`REALTIME_SECRET` not set). - **Subscribe**: the token is checked before the upgrade, so a bad one fails the handshake with 401. The first message is `{"type":"snapshot","events":[...]}` with up to the last 50 events, then each new event as its own message. A client replaces its list with every new snapshot, so a reconnect never duplicates events. Sending exactly `{"type":"ping"}` gets `{"type":"pong"}` without waking the object. - **Cleanup**: once a batch ends in `succeeded` or `failed`, an alarm is set 24 hours out (any later event pushes it back). A run that never finishes is cleaned up 7 days after its last event. The alarm closes the sockets with `1000 "run expired"` and deletes the object's storage. - Run ids must match `[A-Za-z0-9._:-]{1,128}`. The object for a run is `RUN_CHANNEL.getByName(runId)`. ## Tokens The server signs tokens with `REALTIME_SECRET`; the edge only verifies them. ```text crt1.<payload>.<signature> payload = base64url(JSON {run, scope, exp, user?}) scope: "publish" | "subscribe" | "llm", exp in unix seconds signature = base64url(HMAC-SHA256(key = REALTIME_SECRET, message = <payload> as it appears in the token)) ``` base64url has no padding. The edge accepts a token when the signature is valid (checked in constant time), `scope` matches the route, `run` equals the run id in the path and `exp` is in the future. | Token | Minted by | Lifetime | |---|---|---| | subscribe | `subscribeUrl()`, returned as `run.live.url` in every active run the app API hands out | 15 minutes | | publish, server side | `publishEvents()`, per batch | 5 minutes | | publish, the box's relay | `startRun` | 75 minutes (the run's budget plus setup and upload) | | llm | `startRun`, only with credits on | 75 minutes | ## Without realtime When `CRESCENT_EDGE_URL` or `REALTIME_SECRET` is missing on the server, `run.live` is `null`, nothing is published and the apps poll the games list instead, showing the progress line the workflow copies every 15 seconds. ## The rest of the edge Worker `edge/src/index.ts` is one Worker with three jobs: 1. **Proxy.** Every path except `/rt/*` and `/llm/*` goes to the Next.js app at `ORIGIN_URL`: the web app, the share pages, the well-known files, the app API and Nova's chat stream. Every method, header and body is passed on and responses stream back unbuffered, so the chat's NDJSON arrives line by line. The client never gets a redirect: GET and HEAD redirects are followed by the Worker, and any other 3xx becomes a 502. The Apple app site association file is always served as a 200 `application/json` and cached for about five minutes. 2. **Live progress**, above. 3. **The meter.** With credits on, the builder's Claude calls come through `/llm/:runId/v1/messages` on an `llm` token for that run only. The Worker holds the real Anthropic key. See [Credits](https://docs.moonspell.app/moonspell/credits.md). Running it locally is in [Development setup](https://docs.moonspell.app/moonspell/dev-setup.md), and checking it in [Testing](https://docs.moonspell.app/moonspell/testing.md). --- # Community builds Source: https://docs.moonspell.app/moonspell/community-builds > How any Moonspell player can keep improving someone else's game through a private draft, a merge, a judge, and the author's review. Any player can keep making someone else's game better. Their changes build a **draft** that only they play. When they are happy with it they **merge** it, a **judge** decides whether it becomes the game's next version, and the game's **author** can still cancel it or give it badges. A game's credits are its accepted changes. The code is in `src/lib/community.ts`, `community-merge.ts`, `community-cancel.ts`, `judge.ts` and `badges.ts`. The behaviour each app shows is in `spec/features/community.md`. ## The player's side 1. **Open the chat about the game.** From a game's card the player opens a chat about it. `POST /eve/v1/app/chats/:chatId/build` with `{gameId}` makes that chat (`app_chats.contributing`), or returns the one the player already has, and sends Nova a hidden `[Community build]` message. Nova opens with what the game is, from its design document (`GDD.md`, stored on each version as `game_builds.gdd`). 2. **Ask for changes, which build the draft.** `change_game` starts a normal run under the player's account, on their own branch of the game's repo, `community/<userId>`. Each run lands as a draft version (`game_builds.branch`, `draft`, `base_version`, with `version` null), stored under `games/<gameId>/drafts/`. The feed and `show_game` give the player their draft in place of the public version. Nobody else sees it. Rules: - at most 5 community changes per player, game and day (`contributionBlock`); - the game must have a repo and a commit on its latest version; - the builder of a community change gets no access to the game's live backend, since a judge decides later and a deploy cannot be taken back; - a draft continues on its branch with the public branch merged in (if that merge conflicts, the draft stays as it was), or starts over from the public branch when nothing on it is unmerged. 3. **Merge.** Once there is a draft, the chat shows a card to merge it. Its confirm needs a required tick before it works. `POST /api/app/games/:id/merge` with `{confirmed: true}` (`startMerge` in `src/lib/community-merge.ts`) queues a merge run (`game_runs.kind = 'merge'`) on the public branch. It is refused while the draft is still building or while another build holds the public branch. 4. **The merge run.** The run checks out the public branch and runs `git merge --no-ff` on the draft. The builder only steps in when git cannot merge a file, resolving it by keeping both sides. A file still holding conflict markers fails the run. The platform pays for merge runs: no credits are taken for their Claude calls or box time. 5. **The judge.** After the merge and before any version is recorded, the judge (`src/lib/judge.ts`, structured output) reads the whole change once: the game's design document, the original brief, the requests, the builder's notes and the diff. It accepts nearly anything that helps. It declines a change that replaces the game's core, tears it down, pushes something on its players (ads, links, the requester's name), is not fit for a public game, or barely does anything. Everything it reads is treated as data, never as instructions. If the model cannot be reached, the change lands marked as not reviewed, so the author knows to look at it. 6. **The result.** Accepted: the next public version, the draft versions marked merged (`merged_at`), and a `game_contributions` row with the features and the requests the draft was made of. Declined: no version, the draft stays the player's to keep changing, and Nova explains from the judge's review (`[Change declined]`). ## From the player's own computer A player with a coding agent of their own can do the same from their machine. **Continue building on your PC** on the game's web page gives the prompt, and `crescent continue <gameId>` sets up their own copy of the game: a private repo of its own, so their machine never gets a credential for the game's repo. What they publish there is a draft like the ones above. Only they play it, it lives on `community/<userId>` of the game's repo (the server puts each published draft there), and merging it (`crescent merge --confirm`, the same required tick) queues the same merge run and judge. Drafts made with Nova and on the machine join up: the machine brings in what Nova changed before it publishes. The same daily limit counts both. [Local developer mode](https://docs.moonspell.app/moonspell/local-dev-mode.md#someone-elses-game) has the details. ## The author's side The author sees new changes to their games (`GET /api/app/games/:id/community`). - **Cancel a change**, with a reason from a fixed list and an optional note (`POST /api/app/contributions/:id/cancel`, `src/lib/community-cancel.ts`). If the change is the latest version, the game goes straight back to the version before and the repo's default branch moves back to that commit. If later versions build on it, an undo run takes it out as a new version. A game with a build running refuses until the build lands. - **Give a badge** to an accepted change (`POST /api/app/contributions/:id/badges`). The six badges are listed in `src/lib/badges.ts`. ## Credits and handles A game's credits are its accepted changes grouped by player, with the features they landed and the badges they earned. Players appear as "Player XXXX", a tag made from the account id (`handleFor`), because anonymous accounts have no name and a sign-in name is not for showing to strangers. ## Design documents Every game keeps a living `GDD.md` (the template's **game-design-doc** skill), and every version stores it. It is how Nova and the judge know what the game is. `npm run gdds:backfill` fills it in for older versions from their repos; a game without one falls back to its latest notes. ## Limits - One draft per player and game, merged whole. Drafts from several players are not merged into each other: each merges into the public version on its own. - Community runs are only open when the deployment meters runs or has an Anthropic API key (`communityRunsAllowed()` in `src/lib/community-gate.ts`). --- # Credits Source: https://docs.moonspell.app/moonspell/credits > How the Moonspell app prices and meters building, from the credit ledger and monthly refill to the edge's meter for the builder's Claude calls and the confirm box for generated assets. Players pay for building in **credits**. One credit is one cent of a plan's face value, and everything a player uses is charged at twice what it costs the platform. The prices are in `src/lib/credit-prices.ts` (`PLANS`, `MARKUP`, `MODEL_PRICES` and the asset prices), the ledger in `src/lib/credits.ts`, the usage screen in `src/lib/usage.ts`. Usage is always recorded and taken off the balance, so the usage screen is right whether or not credits are enforced. `CRESCENT_METERING=on` enforces them. | | Metering off | Metering on | |---|---|---| | The builder's Claude calls | Read from the run's log when the run ends (`recordBuilderUsage`) | Charged by the edge's meter, call by call | | Nova, box time | Charged as they happen | Same | | Art, sounds and 3D models | Made without asking, then recorded | Wait for the player's yes in the confirm box; paid up front, failures refunded | | At zero | Nothing is refused | Builds stop and ship their last checkpoint; chat answers 402 | ## What is charged | What | How | |---|---| | The builder's Claude calls | Per call, by model and tokens (input, output, cache reads and writes), plus web searches | | Nova's model calls | Per step, by `agent/hooks/meter.ts` on eve's `step.completed` event | | The run's sandbox | On every 15-second poll (`pollRun`) | | Art, sounds and 3D models | Up front, after the player approves | Not charged to the player: a game's cover art, and merge runs for [community builds](https://docs.moonspell.app/moonspell/community-builds.md). Both are recorded at cost with no credits taken. ## Monthly credits Every balance goes back to the monthly allowance once a month, on the day of the month the account was made. Whatever was left does not carry over. The refill is lazy: `refillIfDue` runs whenever a balance is read or a call is authorized, and writes a `monthly` ledger row once per period. | Variable | Default | What | |---|---|---| | `CRESCENT_MONTHLY_CREDITS` | 1000 | What every balance refills to | | `CRESCENT_STARTER_CREDITS` | none | A one-time bonus on top of the first month | | `CRESCENT_OWNER_USER_IDS` | none | Accounts that also see the platform's cost and every player's usage | ## The ledger `users.credit_balance` holds the balance, and `credit_ledger` every change, in micro-credits (1 credit is 1,000,000). Each line also records what it cost the platform (`cost_usd`). Every change carries a unique key, so repeating one changes nothing: that is what makes the edge's and the workflow's retries safe. A balance never goes below zero. `npm run credits:grant -- <userId> <credits> [note]` grants credits by hand. ## The usage screen `GET /api/app/usage` returns what is left of the month's credits, when they refill, and a log grouped for people: one entry per build (its Claude tokens per model, sandbox minutes, assets), one per day of chat with Nova, and refills, grants and refunds on their own. `?back=1` is the month before. ## The builder's meter With metering on, the sandbox never gets an Anthropic key. Claude Code gets `ANTHROPIC_BASE_URL` pointing at the edge (`/llm/<runId>`) and an `llm` token good for that run only (`llmToken()` in `src/lib/realtime.ts`). The edge Worker holds the real key (`edge/src/index.ts`, `edge/src/meter.ts`). 1. **Before each call** the edge asks the server (`POST /api/internal/meter/authorize`). A run that has ended, a model not in `MODEL_PRICES` or a player at zero is refused. Otherwise the answer says how many output tokens the balance pays for, and that becomes the call's `max_tokens`. 2. **When Anthropic reports the input** (the stream's first event), the edge charges it (`POST /api/internal/meter/charge`). If that used up the balance, the call is cut off. 3. **When the call ends**, however it ends, the edge charges the output: Anthropic's final count, or an estimate from the text that went by if the call was cut off. At zero the next call gets a 402 with `billing_error`, which makes Claude Code exit. The next poll finds the balance gone, stops the script and ships the last checkpoint. Nova gets `[Build stopped]`. Only `/v1/messages` and `/v1/messages/count_tokens` are reachable through the meter. The meter lives at the edge rather than in the box because the builder has full permissions in its box and follows player-written requests, so anything counted or kept there could be read or changed. ## Nova `POST /eve/v1/app/chats/:chatId/messages` answers 402 with `code: "out_of_credits"` at zero, and `make_game` and `change_game` will not start a build. The meter hook charges each step and ends the turn when that used up the balance. Moonspell's own system messages to Nova, such as `[Build ready]`, are charged but never cancelled, because the player still has to hear what happened. ## The confirm box Art, sounds and 3D models wait for the player when metering is on. After the builder's pass the workflow prices the builder's list (`quoteAssets`), stores the quote on the run, publishes an `assets` event and waits on the hook `run-assets:<runId>` for up to ten minutes. The app shows what would be made, the price and the balance. - **Yes**: `POST /api/app/runs/:id/assets` with `{approve: true}` takes the whole price up front, or answers 402 when the balance is short. When the assets are done, `settleAssets` returns the share of whatever failed. - **No, or no answer**: the version ships with its placeholders, and nothing is charged. ## Going live The edge needs the server's meter routes, and the server needs the edge's meter, so the order matters: 1. The edge Worker needs a Workers plan with more CPU per request than the free plan, since it reads each stream. 2. Apply the migrations: `npm run db:migrate`. 3. Set the edge secrets `ANTHROPIC_API_KEY` and `INTERNAL_API_SECRET` (the server's value) and deploy the edge. 4. Deploy the server with `CRESCENT_METERING` still off, so the routes exist. 5. Grant credits to testers, then set `CRESCENT_METERING=on` and redeploy. To turn it off, unset `CRESCENT_METERING` and redeploy. Runs go back to the key on the server, and balances stay as they are. --- # Sharing Source: https://docs.moonspell.app/moonspell/sharing > How every Moonspell version gets its own share link, what the shared-game page shows, and how the apps and the App Clip open it. Every version of a game has a link anyone can open: ```text https://moonspell.app/g/<buildId> ``` The build id is a random UUID, and it is the only capability: whoever holds the link can play that version, and sees only its title, cover, version and notes, never the repo, the request or the chat (`sharedBuild()` in `src/lib/app-clip.ts`). Each version keeps its own link, so a shared link always plays the version that was shared. The share URL is built from `PUBLIC_BASE_URL`, never from request headers. ## What happens when someone opens a link | Where | What opens | |---|---| | A phone with the Moonspell app | The app, through a verified link for `/g/*`. It looks the version up (`GET /api/app/previews/:buildId`) and plays its own platform's export | | An iPhone without the app | The shared-game page, with Safari's App Clip card. The App Clip plays that version without installing the app | | Any browser | The shared-game page, with **Play on web** when the version has a web build | In the app, a link covers the tabs with the game's cover and the download's progress until the game starts, and says so when it can't be opened. The App Clip is part of the private iOS app. On the server side it only needs the preview route and the well-known file below. ## The shared-game page `src/app/g/[buildId]/page.tsx` is the game's card: the cover, the title, the version and the notes, then the ways to play: - **Play on web** opens `/play/<buildId>` in the web app, when the version has a web build. - The app button, on a phone only (a computer gets the one button alone, labelled **Play**). On an iPhone (`appClipPlayLink()`): **Play on App Clip** with Apple's App Clip link once `CRESCENT_APP_STORE_ID` is set; until then **Open in the app**, a `moonspell://play` link that opens an installed app. With the app installed, iOS always opens the app, never the clip. - **Play in the app** on Android: an `intent://` link (`androidPlayIntent()`) that falls back to the page without the app. - A download button, when there is somewhere to download from (`CRESCENT_APP_STORE_ID`, else `CRESCENT_TESTFLIGHT_URL`). The page is not indexed. `opengraph-image.tsx` next to it draws the link preview. Safari's App Clip card comes from the `apple-itunes-app` meta tag, which the page only emits once `CRESCENT_APP_STORE_ID` is set. `next.config.ts` renders metadata into the head before the body so Safari reads that tag in time. ## Well-known files | File | Route | What it does | |---|---|---| | `/.well-known/apple-app-site-association` | `src/app/.well-known/apple-app-site-association/route.ts` | Links `/g/*` to the iOS app and declares the App Clip | | `/.well-known/assetlinks.json` | `src/app/.well-known/assetlinks.json/route.ts` | Verifies `/g/` links for the Android app; extra signing certificate fingerprints come from `ANDROID_CERT_SHA256` | Apple refuses this file behind a redirect or with the wrong content type, which is why the edge Worker serves it with no redirect, as `application/json`, cached for about five minutes (see [Live progress](https://docs.moonspell.app/moonspell/realtime.md#the-rest-of-the-edge-worker)). ## Multiplayer invites A room invite is the version's share link plus `?room=CODE`. The app or App Clip passes the link it was opened with to the game, and the engine's `useLaunchRoomId()` drops the friend into that room. Making codes and invite links is part of the engine: see [Networking](https://docs.moonspell.app/engine/net.md). ## Sharing from the apps Every app shares the version's `shareUrl` (the system share sheet, or copy). The web player's share menu offers both the `/g/<buildId>` link and its own `/play/<buildId>` link. What each app does is listed in `spec/features/share.md`. --- # Three apps, one product Source: https://docs.moonspell.app/moonspell/three-apps > How the iOS, Android and web apps stay one Moonspell product with no shared UI code, through generated contracts, one server API and per-feature specs checked by the parity script. Moonspell is three apps that share no UI code: iOS (SwiftUI), Android (Compose) and web (React). The web app is public, in `src/web/` (client code) and `src/app/(app)/` (its routes). The iOS and Android apps are private. They stay the same product because they follow the same three things: 1. **Contracts** (`contracts/`): names, colours, metrics and every shared sentence, generated into each app. 2. **One server API**: the same routes, sessions and shapes for every app (see [Architecture](https://docs.moonspell.app/moonspell/architecture.md#the-api)). `X-Crescent-Client: ios | android | web` picks each version's export (see [Builds and versions](https://docs.moonspell.app/moonspell/builds.md#which-export-each-app-gets)). 3. **Feature specs** (`spec/features/`): what each feature does, one numbered behaviour per row, with a column per app. Games are not part of this: a game is one source, exported once per platform, and what differs per platform belongs in the engine. See [Platform layer](https://docs.moonspell.app/engine/platform-layer.md). ## Contracts | File | Holds | |---|---| | `contracts/identity.json` | The product's names, the share host and URL scheme | | `contracts/tokens.json` | The look: colours, corner radii, the gutter, layout limits (the web app's one column is capped at `layout.maxWidth`, 1024) | | `contracts/copy.json` | Every sentence the apps share, by key; `{name}` in a string marks a value filled in at run time | `npm run contracts` (`contracts/gen.mjs`) writes them into each app. In a public clone it writes and checks the web files only, `src/web/generated/contracts.ts` and `src/web/generated/contracts.css`. Never edit a generated file. ```bash npm run contracts # regenerate npm run contracts -- --check # fail when a generated file is out of date ``` ## Feature specs Each file in `spec/features/` (`account.md`, `community.md`, `create.md`, `feed.md`, `home.md`, `play.md`, `share.md`) has one table: | ID | Behaviour | iOS | Android | Web | |---|---|---|---|---| | PLAY-1 | Play runs the version's build for this platform | yes | yes | yes | - `yes`: built on that app. - `no`: not built yet. The notes under the table say why, or what stands in for it. - `n/a`: does not apply to that app (an App Clip on Android). `npm run parity` (`spec/parity.mjs`) prints, per app, what is built and which behaviours are missing. ## Changing something | The change | Where it starts | |---|---| | A colour, a radius, the product's name, a sentence | `contracts/`, then `npm run contracts` | | A behaviour (what a screen or button does) | A row in `spec/features/`, then each app, or the cell left `no` on purpose | | A new server route or field | The server, then each app's API client (`src/web/api.ts` for the web) | | How games run on a platform | The engine (`templates/crescent-game/src/engine/`), never a game or one app's shell | Contributors outside the project can change the contracts, the specs and the web app. Changes that need the phone apps are built there by the maintainers; mark the iOS and Android cells `no` and say so in the notes. ## The web app | Route | Screen | |---|---| | `/` | Home: latest played, your games | | `/feed` | Every player's games | | `/create` | The conversation with Nova (`?game=<id>` to change one of yours, `?help=<id>` to help build someone else's) | | `/play/<buildId>` | The player, with the chat about the game beside it | | `/settings` | Account and credits | The web app runs in the server's Next.js project, so it shares the API's types and has no deployment of its own. Games are phone-sized in it and run in an `<iframe>` on a separate play origin (see [Builds and versions](https://docs.moonspell.app/moonspell/builds.md#playing-a-version-on-the-web)), talking to the app with `postMessage` (`src/web/play.ts`). The client is `src/web/api.ts`, the conversation store `src/web/chat.ts`, the player frame `src/web/GameFrame.tsx`. How the engine itself runs in a browser is in [Web](https://docs.moonspell.app/platforms/web.md). --- # Deploy Source: https://docs.moonspell.app/moonspell/deploy > The order in which the hosted Moonspell server, database, storage, game repos, sandbox snapshot, edge Worker and optional services are set up, and which environment variables each needs. This page lists what a production deployment of the Moonspell server needs, in an order where each step only depends on the ones before it. It describes the hosted product for readers and contributors. A full deployment also needs the private bundling CLI (for the sandbox snapshot) and the private phone apps, so a public clone can deploy the server, the web app and the edge, but cannot run builds. See [What is open and what is private](https://docs.moonspell.app/start/open-source.md). Secrets go in your host's secret stores, never in the repo. `.env.example` lists every server variable with a comment. Generate long random strings with something like `openssl rand -base64 48`. ## 1. Database 1. Create a Postgres database (the code uses `@neondatabase/serverless`, so Neon) and take its connection string as `DATABASE_URL`. 2. Apply the schema: `npm run db:migrate`. It applies `db/migrations/*.sql` in name order, once each, records them in `schema_migrations`, and reads `DATABASE_URL` from the environment or `.env.local`. Run it again after adding a migration. ## 2. Server The server is one Next.js project at the repo root. `vercel.ts` pins the framework to Next.js, and `package.json` needs Node 24. Nova (eve, at `/eve/v1/*`) and the workflows (Workflow SDK, `src/workflows/`) ship inside it through `withEve(withWorkflow(...))` in `next.config.ts`. 1. Create the project and add a Blob store (`BLOB_READ_WRITE_TOKEN`). 2. Production must be reachable without deployment protection, because the edge proxies it and Apple fetches the app site association file through it. 3. Set the environment (bare values, without quotes): | Variable | Value | |---|---| | `DATABASE_URL` | From step 1 (secret) | | `BLOB_READ_WRITE_TOKEN` | From the Blob store | | `APP_SESSION_SECRET` | Long random string (secret; changing it signs everyone out) | | `INTERNAL_API_SECRET` | Long random string (secret) | | `PUBLIC_BASE_URL` | The public host, the edge's origin | | `CRESCENT_APP_URL` | Empty on production (the production domain is used); set it on previews | | `CRESCENT_EDGE_URL`, `REALTIME_SECRET` | Step 5 (the secret is shared with the edge) | | `ANTHROPIC_API_KEY` | Step 7 (secret) | | `CRESCENT_WORKER_MODEL` | Optional, default `claude-opus-5-5` | | `DAYTONA_API_KEY`, `DAYTONA_SNAPSHOT`, `DAYTONA_API_URL` | Step 4 (the URL only for a non-default endpoint) | | `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_INSTALLATION_ID`, `GAMES_GITHUB_ORG` | Step 3 | | `CLOUDFLARE_GATEWAY_URL`, `CLOUDFLARE_APPS_ACCOUNT_ID`, `CLOUDFLARE_GATEWAY_SECRET` | Optional, step 6 | | `CRESCENT_PLAY_ORIGIN` | Optional: a dedicated host for running web games (see [Builds and versions](https://docs.moonspell.app/moonspell/builds.md#playing-a-version-on-the-web)) | | `GOOGLE_WEB_CLIENT_ID`, `GOOGLE_IOS_CLIENT_ID`, `GOOGLE_ANDROID_CLIENT_ID`, `CRESCENT_REQUIRE_SIGN_IN` | Sign-in (see [Architecture](https://docs.moonspell.app/moonspell/architecture.md#accounts)) | | `CRESCENT_METERING` and the other credit variables | See [Credits](https://docs.moonspell.app/moonspell/credits.md#going-live), which has its own order | | `CRESCENT_STARTER_MANIFEST_URL`, `CRESCENT_STARTER_ANDROID_MANIFEST_URL`, `CRESCENT_STARTER_WEB_MANIFEST_URL`, `CRESCENT_STARTER_COVER_URL` | The starter game (below); unset, new accounts start with no games | | `HIGGSFIELD_CLIENT_ID`, `HIGGSFIELD_REFRESH_TOKEN` | Optional: covers, sounds, art and 3D models; unset, games keep their placeholders | | `CRESCENT_SUPPORT_EMAIL` | The contact on `/support` and `/privacy` | | `CRESCENT_APP_STORE_ID`, `CRESCENT_TESTFLIGHT_URL`, `APPLE_APP_BUNDLE_ID`, `ANDROID_CERT_SHA256` | The phone apps' store and link settings | 4. Before deploying, check locally: `npm run typecheck`, `npm run lint`, `npm test`, `npm run build`. ## 3. Game repos Every game lives in its own private repo in `GAMES_GITHUB_ORG`, named `cm-<slug>-<id>` (`repoNameFor` in `src/lib/games.ts`). 1. Create the org. 2. Create a GitHub App with repository permissions **Administration: read and write** (to create repos) and **Contents: read and write**. No webhooks. 3. Install it on the org with access to all repositories, so it can reach the repos it creates. 4. Set `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY` (a one-line value with literal `\n` works) and `GITHUB_APP_INSTALLATION_ID`. ## 4. The sandbox snapshot Runs start from a sandbox snapshot built by `scripts/create-run-snapshot.ts`: Node 24, git, Claude Code, the bundling CLI and the game template with its dependencies, exported once while building so a snapshot that cannot export never ships. ```bash npm run snapshot:pack # packs the private CLI into scripts/vendor/ npm run snapshot -- [name] [cpu] [memGB] [diskGB] # builds the snapshot, with DAYTONA_API_KEY in .env.local ``` Set `DAYTONA_SNAPSHOT` to the name. Rebuild it whenever `templates/crescent-game/` changes, since new games start from the template inside it; a new name per build lets live runs keep the old one. `snapshot:pack` needs the private CLI and stops with one sentence in a public clone. ## 5. Edge Worker `edge/` is a Cloudflare Worker with one Durable Object class, `RunChannel` (`edge/wrangler.jsonc`). 1. Set `vars.ORIGIN_URL` in `edge/wrangler.jsonc` to the server's production URL, with no trailing slash. 2. Deploy: `cd edge && npm install && npm run typecheck && npx wrangler deploy`. 3. `npx wrangler secret put REALTIME_SECRET` with the server's value. Until it is set, `/rt/*` answers 503. 4. With credits on, also `ANTHROPIC_API_KEY` and `INTERNAL_API_SECRET` as secrets ([Credits](https://docs.moonspell.app/moonspell/credits.md#going-live)). 5. Set `CRESCENT_EDGE_URL` and `PUBLIC_BASE_URL` on the server to the edge's origin and redeploy. 6. Check: ```bash curl -si https://<edge>/rt/health # "realtime":true curl -si https://<edge>/.well-known/apple-app-site-association # 200, application/json, no location header curl -si https://<edge>/ # the web app ``` A custom domain is attached through `routes` in `edge/wrangler.jsonc`, so the same Worker and Durable Objects keep serving it. ## 6. Game backends (optional) Games get their own Cloudflare Worker through a Cloudflare gateway that accepts tokens scoped to one game's resources. The gateway is a separate service, not part of this repo. With all three of `CLOUDFLARE_GATEWAY_URL`, `CLOUDFLARE_APPS_ACCOUNT_ID` and `CLOUDFLARE_GATEWAY_SECRET` set, runs build games with live leaderboards, presence and rooms (`cloudflareRunEnv` in `src/lib/cloudflare-gateway.ts`). With any of them missing, games keep their data on the phone and Nova does not promise online play. What a game backend is made of is in [Backend overview](https://docs.moonspell.app/backend/overview.md). ## 7. Anthropic Nova and the worker both use `ANTHROPIC_API_KEY` (with credits on, the worker reaches it through the edge instead). Use a dedicated key with a spend limit: the builder runs player-written requests with full permissions in its sandbox, up to 160 plus 40 turns per run. ## 8. The starter game `npm run starter:upload` exports the template's game and uploads it, then prints the starter variables to set. It needs the private CLI. Run it again whenever the template's game changes. ## 9. Smoke test 1. Open the web app, sign in, and ask Nova for a game. 2. The building card shows live progress (the edge works), then turns into a play card, and the game plays. 3. The workflow's runs show each step; the sandbox comes and goes; the games org has a new repo with the commit. 4. Open the version's share link in a private window: the shared-game page shows the version. 5. Ask for a change to the same game: version 2 builds from the repo. The phone apps' store setup (signing, App Clip, store records) belongs to the private apps and is not covered here. --- # Development setup Source: https://docs.moonspell.app/moonspell/dev-setup > How to run the Moonspell server, the web app, Nova and the edge Worker locally from a public clone, and what does and does not work without the private app and CLI. This page is for working on the hosted product's public parts: the server, Nova, the web app, the edge Worker, the contracts and the specs. To build games with the engine, you do not need any of this: start with [Quickstart](https://docs.moonspell.app/start/quickstart.md). ## What works from a public clone | Works | Does not work without the private parts | |---|---| | The server and the web app at `http://localhost:3000` | Worker runs: the sandbox snapshot needs the private bundling CLI (`npm run snapshot:pack` stops with one sentence) | | Accounts, the feed, the shared-game page, the play page | `npm run starter:upload`, `npm run android:backfill`, `npm run web:backfill` (they export games) | | Chatting with Nova (with an Anthropic API key) | The iOS and Android apps and the App Clip | | The edge Worker under `wrangler dev`: the proxy, live progress, the meter | Generated phone contracts (`npm run contracts` writes the web files only) | | `npm run typecheck`, `npm run lint`, `npm test`, `npm run build` | | | `npm run contracts`, `npm run parity`, `npm run db:migrate` | | Nova answers and starts runs locally, but without a sandbox snapshot a run fails at its sandbox step, and Nova treats it as a failure on the platform's side (she retries once, then gives up). See [What is open and what is private](https://docs.moonspell.app/start/open-source.md). ## Prerequisites - Node 24 (`engines` in the root `package.json`) and npm. - A Postgres database. The server uses `@neondatabase/serverless`, so a Neon database or branch fits best. Use a database of your own, never one a deployment uses. - An Anthropic API key, for Nova. - Optional: a Vercel Blob token (chat pictures and uploads), and a Cloudflare account for `wrangler`. ## The server ```bash npm install cp .env.example .env.local # then fill it in (below) npm run db:migrate # applies db/migrations to DATABASE_URL npm run dev # next dev on :3000: the web app, the API, Nova at /eve/v1/*, the workflows ``` `npm run dev` sets `NODE_OPTIONS=--network-family-autoselection-attempt-timeout=2000`, which gives each address of a host two seconds to connect instead of Node's default, so database calls do not time out on slow networks. The smallest `.env.local` for the web app and Nova: | Variable | Value | |---|---| | `DATABASE_URL` | Your database's connection string | | `APP_SESSION_SECRET` | Any long random string | | `INTERNAL_API_SECRET` | Any long random string | | `ANTHROPIC_API_KEY` | Your key; Nova needs it | | `PUBLIC_BASE_URL` | `http://localhost:3000` (`.env.example` has the hosted value) | | `CRESCENT_EDGE_URL`, `REALTIME_SECRET` | Empty, or a local edge (below) | | `CRESCENT_REQUIRE_SIGN_IN` | `off`, unless you set up Google sign-in with `GOOGLE_WEB_CLIENT_ID` | | `BLOB_READ_WRITE_TOKEN` | Optional: picture uploads in the chat | `CRESCENT_APP_URL` defaults to `http://localhost:3000`; set it when `next dev` runs on another port. Every other variable in `.env.example` is for real runs or the hosted product, and each has a comment. [Deploy](https://docs.moonspell.app/moonspell/deploy.md) lists them by step. Locally the web app runs games on the other loopback name: open the app on `http://localhost:3000` and games load from `http://127.0.0.1:3000`, or the other way round (`src/web/play.ts`). ## The edge Worker ```bash cd edge npm install npm run typecheck printf 'REALTIME_SECRET=any-local-test-secret\n' > .dev.vars # git-ignored, never commit npx wrangler dev --local --var ORIGIN_URL:http://localhost:3000 # proxies to next dev, serves /rt/* on :8787 ``` To send the server's events through it, set `CRESCENT_EDGE_URL=http://localhost:8787` and the same `REALTIME_SECRET` in `.env.local`. A sandbox runs on the provider's network and cannot reach a Worker on your machine, so live events from a real run need a deployed edge. To try the meter against a stand-in for Anthropic, also pass `--var ANTHROPIC_UPSTREAM:http://localhost:<port>` and put `ANTHROPIC_API_KEY` and `INTERNAL_API_SECRET` in `.dev.vars`. After editing `edge/wrangler.jsonc`, run `npx wrangler types` without a `.dev.vars` present, and commit the regenerated `worker-configuration.d.ts`. ## Contracts, specs and docs ```bash npm run contracts -- --check # generated web files are current npm run parity # what each app is missing npm --prefix docs-site run start -- --port 3100 # this site ``` See [Three apps, one product](https://docs.moonspell.app/moonspell/three-apps.md). ## Troubleshooting | Symptom | Cause | |---|---| | Every edge page answers 503 | `ORIGIN_URL` is unset or still the placeholder | | The edge answers 508 | `ORIGIN_URL` points at the Worker itself | | `/rt/*` answers 503 | `REALTIME_SECRET` is not set on the edge | | The app site association file answers 502 | The origin returned something that is not JSON, often a deployment-protection page | | A run fails at once with "No Claude credential" | No `ANTHROPIC_API_KEY` where the workflow runs | | A run fails with "DAYTONA_SNAPSHOT is not configured" | No sandbox snapshot; expected in a public clone | | Making a game answers `sign_in_required` | Sign-in is on: set `CRESCENT_REQUIRE_SIGN_IN=off` locally, or sign in with Google | --- # Testing Source: https://docs.moonspell.app/moonspell/testing > The checks for the Moonspell server, the edge Worker, the engine and template, game backends, the contracts and these docs, and how playtests decide which games get a feed video. Every public part of Moonspell has its own checks. Run the ones for what you changed before opening a pull request ([Contributing](https://docs.moonspell.app/contributing.md) has the short list). ## Server ```bash npm run typecheck # tsc over the app, Nova (agent/) and the workflows npm run lint # eslint (next core-web-vitals + typescript) npm test # node --test over src/**/*.test.ts and edge/src/**/*.test.ts npm run build # next build, with eve and the Workflow SDK compiled in ``` Builds and tests need no environment variables: the database and SDK clients are created lazily. The unit tests sit next to the code they test, in `src/lib/`: | Test | Covers | |---|---| | `client.test.ts` | Each app gets its own export of a version, and the other platforms' URLs never leave the server | | `web-play.test.ts` | The page that runs a web export, and what may reach it | | `community-drafts.test.ts` | How the run script picks a draft's branch | | `judge.test.ts` | Reading the judge's verdict | | `credit-prices.test.ts` | Credit arithmetic: model calls, sandbox time, asset quotes and refunds | | `sign-in.test.ts` | Google and Apple sign-in rules | | `models.test.ts`, `sprite-sheet.test.ts` | The builder's 3D model and sprite requests, turning generated meshes to face forward, and cutting generated images into sprite cells | | `playtest.test.ts`, `gameplay-clip.test.ts` | Parsing `playtest.json`, and picking a feed clip from a recording | `edge/src/meter.test.ts` runs with them and covers reading Anthropic's streamed usage. An end-to-end run needs the whole hosted environment, including the private bundling CLI in the sandbox snapshot, so it cannot be run from a public clone. See [Development setup](https://docs.moonspell.app/moonspell/dev-setup.md#what-works-from-a-public-clone). ## Edge Worker ```bash cd edge npm install npm run typecheck printf 'REALTIME_SECRET=any-local-test-secret\n' > .dev.vars npx wrangler dev --local --var ORIGIN_URL:http://localhost:3000 # with npm run dev on :3000 ``` Then, in another terminal: ```bash curl -s localhost:8787/rt/health # {"ok":true,"realtime":true} curl -si localhost:8787/.well-known/apple-app-site-association # 200, application/json, no location header curl -si localhost:8787/g/00000000-0000-0000-0000-000000000000 # proxied to Next (not found for an unknown build) ``` Publish and subscribe to one run with tokens signed by the same secret: ```bash mint() { REALTIME_SECRET=any-local-test-secret node -e ' const c=require("node:crypto"); const [run,scope]=process.argv.slice(1); const p=Buffer.from(JSON.stringify({run,scope,exp:Math.floor(Date.now()/1000)+600})).toString("base64url"); console.log("crt1."+p+"."+c.createHmac("sha256",process.env.REALTIME_SECRET).update(p).digest("base64url"))' "$1" "$2"; } curl -s -X POST localhost:8787/rt/runs/test-run/events \ -H "Authorization: Bearer $(mint test-run publish)" -H 'Content-Type: application/json' \ -d '{"events":[{"type":"progress","at":"2026-01-01T12:00:00Z","text":"hello"}]}' # {"ok":true,"accepted":1,...} npx wscat -c "ws://localhost:8787/rt/runs/test-run?token=$(mint test-run subscribe)" # first message: the snapshot ``` Also worth checking: a wrong token gets 401 on both routes, 101 events get 413, and an event without `type` gets 400. The protocol is described in [Live progress](https://docs.moonspell.app/moonspell/realtime.md). ## Engine and game template From `templates/crescent-game/`: ```bash npm run typecheck # tsc --noEmit npm test # unit tests in src/engine/**/*.test.ts npm run export:web # a static web build in dist/ ``` The unit tests cover the pure parts of the engine: physics, paths, zones, the level pieces, the character controller, cameras, bots and the nav grid, the combat kit, saves, the turns kit, game feel and the modules. A change that touches how a game looks or plays is also checked by playing it in a browser with `npm run web`; see [Web](https://docs.moonspell.app/platforms/web.md). The same commands check any game repo made from the template. The worker in a hosted run checks itself the same way: a typecheck and an export on every checkpoint (see [Nova and the worker](https://docs.moonspell.app/moonspell/nova-and-the-worker.md#checkpoints-a-run-always-lands-a-version)). ## Game backends The backend scaffold that games copy (`templates/crescent-game/.claude/skills/realtime-backend/scaffold/backend/`) has its own smoke test: ```bash cd backend npm ci npm run typecheck npm run db:local npm run dev & # wrangler dev --local node test/smoke.mjs # WebSocket and HTTP checks against the local Worker node test/smoke.mjs <url> --read-only # against a deployed backend ``` See [Backend overview](https://docs.moonspell.app/backend/overview.md). ## Contracts, specs and docs | What | Command | |---|---| | Generated contract files are current | `npm run contracts -- --check` | | What each app is missing | `npm run parity` | | These docs build, links included | `npm --prefix docs-site run build` | ## Playtests and feed videos A game's video on the feed is the recording of a playtest it passed. Each game's builder writes a `playtest.json` (the template's **playtest** skill): steps such as press a label, hold, tap or drag a point, expect a label, and a `{"mark": "playing"}` step after which the recording counts. `src/lib/playtest.ts` reads it, and `src/lib/gameplay-clip.ts` picks the liveliest stretch after the mark. The playtests themselves run against the private iOS app, with `npm run playtest`, so they cannot be run from a public clone. A crash, a missing label or a state that never changes fails the test; a failed game keeps its cover on the feed, and `game_playtests` records why. --- # Game channels Source: https://docs.moonspell.app/moonspell/channels > A channel per game for its author and everyone with an accepted change, with moderation and announcements. Every game has a channel where the people who built it talk. Its members are the game's **author** and every player with an **accepted change** in it (the game's credits). Nobody else can read or write it. Players appear by their tag ("Player 1F7D", `handleFor`), never a name. The author is the moderator: they can remove any message, mute a member (who then reads but can't write), and send **announcements**, which show as such in the channel and reach every other member as a [notification](https://docs.moonspell.app/moonspell/notifications.md). ## How it works ```text App ── POST /api/app/games/:id/channel/messages ──▶ Server: checks membership, mute, rate insert game_channel_messages POST /rt/runs/game:<id>/events ──▶ Edge RunChannel DO "game:<id>" App ◀── wss /rt/runs/game:<id>?token=crt1 ────────────────────────────────────────────┘ (everyone connected) ``` - **Rows are the truth** (`db/migrations/0014_…sql`: `game_channel_messages`, `_mutes`, `_reads`, `_reports`, `user_blocks`); `src/lib/channel.ts` has every rule. - **The edge only fans out.** A channel uses the same Durable Object class as a run's live progress ([Realtime](https://docs.moonspell.app/moonspell/realtime.md)), under the id `game:<gameId>` (`gameChannelId`), with the same `crt1` subscribe token (15 minutes, in `channel.live.url`). So the edge Worker has no channel code. Without the edge configured, `live` is null and an app refetches instead. - **The socket is one-way.** Apps send messages over HTTP, never the socket. ## API All with the app's bearer session. `:id` is the game's id. | Route | What | |---|---| | `GET /api/app/channels` | `{channels: ChannelSummary[], unread}`: the channels the player is in, most recently active first. A game of their own is listed once someone else has a change in it or something was said. | | `GET /api/app/games/:id/channel[?before=<createdAt>]` | The `Channel` (below). 403 `{code: "not_a_member"}` for anyone else. | | `POST /api/app/games/:id/channel/messages` | `{text}` or, from the author, `{text, announcement: true}` → 201 `{message}`. Errors carry `{error, code}`: `forbidden` 403, `muted` 403, `empty` 400, `too_fast` 429 (12 a minute), `too_many_announcements` 429 (3 a day). | | `DELETE /api/app/games/:id/channel/messages/:messageId` | Its writer or the author removes it. | | `POST /api/app/games/:id/channel/messages/:messageId/report` | `{reason?}`. Any member. | | `POST /api/app/games/:id/channel/members/:memberId` | `{muted: boolean}` (the author) → `{muted}`; `{blocked: boolean}` (anyone) → `{blocked: [memberId…]}`. A blocked member's messages aren't shown to that player in any channel. | | `POST /api/app/games/:id/channel/read` | Marks the channel read. | ```ts type ChannelAuthor = { id: string /* memberId, not the account id */; handle: string; role: "author" | "member"; isYou: boolean }; type ChannelMessage = { id: string; kind: "message" | "announcement"; text: string; author: ChannelAuthor; createdAt: string }; type Channel = { game: { id: string; title: string; coverUrl: string | null }; role: "author" | "member"; muted: boolean; // this player was muted members: (ChannelAuthor & { muted: boolean; changes: number })[]; // the author first messages: ChannelMessage[]; // oldest first, at most 60 more: boolean; // older ones exist: ?before=<messages[0].createdAt> blocked: string[]; // memberIds this player blocked live: { url: string } | null; // the WebSocket }; type ChannelSummary = { game: { id: string; title: string; coverUrl: string | null }; role: "author" | "member"; members: number; unread: number; last: { text: string; handle: string; kind: "message" | "announcement"; createdAt: string } | null; }; ``` Messages are plain text: at most 1000 characters (an announcement 280), control characters removed. ## The live socket Open `channel.live.url`. The first frame is `{"type":"snapshot","events":[…]}` (the last 50 events at most), then one event per frame. Send exactly `{"type":"ping"}` every 20 to 30 s; the answer is `{"type":"pong"}`. ```ts { type: "message", at, message: ChannelMessage } // author has no isYou: compare author.id with your own member id { type: "removed", at, id } // drop that message { type: "muted", at, member, muted } // member is a memberId; when it is you, flip `muted` ``` What an app does: 1. `GET …/channel`, show `messages`, remember your own member id (`members.find(isYou).id`). 2. Open the socket. Apply the snapshot's events in order, then every event: a `message` whose id is already shown is ignored (your own posts come back this way too), one whose `author.id` is in `blocked` is dropped, `removed` deletes. 3. When the socket drops, or closes with 1008 or an HTTP 401: `GET …/channel` again (fresh `live.url`, and the messages missed) and reconnect, with backoff (1, 2, 4, up to 15 s). 4. `POST …/read` when the channel is opened and when a message arrives while it is on screen. ## Limits - Members only, so a player whose change is cancelled stays a member only while another accepted change of theirs remains. - Reports are stored (`game_channel_reports`). - No typing or "who's here" yet: the Durable Object would need to know who is connected to say so. --- # Notifications and categories Source: https://docs.moonspell.app/moonspell/notifications > The inbox every app shows, push to phones and browsers, the daily digests, and the category each game is sorted into. An inbox every app shows (`notifications` rows), and a push to the player's devices that says something landed in it. `src/lib/notifications.ts` (inbox, devices, preferences), `src/lib/push.ts` (the senders), `src/lib/digest.ts` (the daily ones). | Kind | When | Tapping it opens | `data` | |---|---|---|---| | `announcement` | A game's author sends one in its [channel](https://docs.moonspell.app/moonspell/channels.md): every other member, at once | The game's channel | `{gameId, messageId}` | | `changes` | Once a day at most: new versions landed, by someone else, on games the player made or helped build (a change they asked for, or a draft of their own) | "What changed" (`GET /api/app/changes?since=`) | `{since}` | | `new_games` | Once a day at most, for players with a device: games whose first version landed | The games feed | `{since}` | The daily two are sent by an hourly cron (`vercel.ts` → `GET /api/cron/notifications`, `CRON_SECRET`) to players who are due and for whom it is between 9:00 and 20:59 (their device's time zone, `users.timezone`; UTC when unknown). A player can turn each kind off; an off kind makes no row and no push. ## API | Route | What | |---|---| | `GET /api/app/notifications` | `{notifications: [{id, kind, title, body, data, read, createdAt}], unread, prefs: {announcements, changes, newGames}}`, newest first | | `POST /api/app/notifications/read` | `{ids: […]}`, or no body for all → `{unread}` | | `PUT /api/app/notifications/prefs` | Any of `{announcements, changes, newGames}` (booleans) → `{prefs}` | | `POST /api/app/devices` | Registers this device (the platform is `X-Crescent-Client`). iOS `{token: <APNs hex>, environment: "production" \| "sandbox", timezone}`; Android `{token: <FCM>, timezone}`; web `{token: <subscription endpoint>, keys: {p256dh, auth}, timezone}`. `timezone` is an IANA name. Call it on every launch once notifications are allowed. | | `DELETE /api/app/devices` | `{token}`: notifications were turned off on this device | | `GET /api/app/changes[?since=<ISO>]` | `{since, total, games: [{game: PublicGame, yours, changes: [{buildId, version, by, summary, features, createdAt}]}]}`: at most a week back, a day back without `since` | | `GET /api/app/config` | now also `push: {vapidPublicKey}` (null when Web Push is off) | ## What a push carries Flat strings: `kind`, `id` (the inbox row) and the row's `data` fields. - **iOS** (APNs): at the top level of the payload, next to `aps` (`aps.alert.title/body`, `thread-id` = the kind). - **Android** (FCM): in `data`; the title and body are in `notification`, on the Android channel named after the kind. - **Web** (Web Push): the payload is `{title, body, data}` as JSON; the service worker (`public/sw.js`) shows it and opens the app on click. ## Keys Each sender is on only when its keys are set (`pushConfigured()`); without any, the inbox still fills. | Platform | Env | Where it comes from | |---|---|---| | iOS | `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_PRIVATE_KEY` (the `.p8`'s text), `APNS_TOPIC` (defaults to the app's bundle id) | An APNs auth key from the Apple developer account; the App ID needs the Push Notifications capability and the app the `aps-environment` entitlement | | Android | `FCM_SERVICE_ACCOUNT` (the service account JSON) | The Firebase project of the Android app; the key is a service account key from its Service accounts page | | Web | `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` | `npx web-push generate-vapid-keys` | ## Categories Every game lands in one category (`src/lib/categories.ts`: Arcade, Runner, Puzzle, Racing, Action, Shooter, Platformer, Adventure, Strategy, Builder, Board & cards, Sports). The categorizer (`src/lib/categorizer.ts`, Claude Haiku 5.5 with a fixed answer format) reads the game's title, first request, latest notes and design document and picks one when the game's first public version lands (`sortGame` in the run workflow); a game keeps its category after that. `npm run categories:backfill -- [--all] [--game <id>] [--dry-run]` sorts older games. Every game the API hands out carries `category: {id, name} | null`, and `GET /api/app/feed` adds `categories: [{id, name, games}]`, the ones that have games, in the list's order. The apps filter the feed on the device. --- # Game repos Source: https://docs.moonspell.app/moonspell/game-repos > Where each Moonspell game's git repo lives, the one interface in front of the two hosts (GitHub and Cloudflare Artifacts), and how a deployment moves games between them. Every game has one private git repo. The builder's sandbox clones it, the workflow pushes each run's commit, community drafts live on `community/<userId>`, a merge lands on the public branch with `git merge --no-ff`, and [local developer mode](https://docs.moonspell.app/moonspell/local-dev-mode.md) hands a developer's machine an hour of write access. Two hosts can hold them, behind one interface (`src/lib/game-repo/`): | Host | Where | Remote | Used for | | --- | --- | --- | --- | | `github` | A GitHub App on `GAMES_GITHUB_ORG` | `https://github.com/<org>/<name>.git`, username `x-access-token` | The default. | | `artifacts` | Cloudflare Artifacts, namespace `GAMES_ARTIFACTS_NAMESPACE`, through the edge Worker's `ARTIFACTS` binding | `https://<account>.artifacts.cloudflare.net/git/<namespace>/<name>.git`, username `x` | Games made while `GAMES_REPO_HOST=artifacts`, and games moved by `scripts/move-repos-to-artifacts.mts`. | The Artifacts host is built and tested against Cloudflare's local simulator. Artifacts is in open beta on the Workers Paid plan; check its [limits](https://developers.cloudflare.com/artifacts/platform/limits/) (per file, per push and per repo) and [pricing](https://developers.cloudflare.com/artifacts/platform/pricing/) before you switch a deployment to it. ## How it works `src/lib/game-repo/index.ts` is the only way in: - `ensureRepo(game)`: creates the repo (private, empty) on first need and saves `games.repo` and, for Artifacts, `games.repo_host`. `createRepo` and `saveRepo` split that for scripts that push before saving. - `readAccess(game)`, `writeAccess(game)`: `{remoteUrl, username, password, expiresAt}`, valid for an hour, for this repo only. Local developer mode's repo-token route returns this (plus `branch: "main"`), so the CLI never knows the host. - `moveBranch(game, sha, branch?)`, `readFile(game, path, ref)`, `commitExists(game, sha)`. - `plannedRepo(game)`: the repo's name before it exists (a run's Cloudflare gateway token is scoped by it). Which host: a game with a repo stays on `games.repo_host` (missing means `github`). A game without a repo gets `GAMES_REPO_HOST` (`github` when unset, or `artifacts`). `games.repo` is `<owner>/<name>` on both hosts: the GitHub org or the Artifacts namespace, then the same `cm-<slug>-<id6>`. The Cloudflare gateway scopes a game's backend by the name only, so moving a repo does not move its Cloudflare resources. The run script (`runScript`, `src/lib/daytona.ts`) clones with the password in `REPO_TOKEN`, through git's credential helper, and unsets it before Claude Code starts. `pushRunCommit` takes a `GitAccess`. ### GitHub `src/lib/game-repo/github.ts`: GitHub App calls on `fetch`, with a JWT signed in Node, so scripts run under `tsx` use the same code. One-repo installation tokens, `contents: read` or `write`, for an hour. ### Artifacts `src/lib/game-repo/artifacts.ts` calls the edge Worker, which holds the binding (`edge/src/repos.ts`): ```text POST /internal/repos/ensure {name, description?} -> {name, remote, defaultBranch, created} POST /internal/repos/info {name} -> {name, remote, defaultBranch, lastPushAt} POST /internal/repos/token {name, scope, ttl?} -> {remote, token, scope, expiresAt} POST /internal/repos/file {name, ref, path} -> the file's bytes, or 404 POST /internal/repos/commit {name, sha} -> {exists} Authorization: Bearer <INTERNAL_API_SECRET> ``` - The binding is used instead of Artifacts' REST API so that the server holds no Cloudflare API token that can reach every namespace, the binding is limited to one namespace, and the local simulator tests the whole path. Switching to REST would be another `RepoHost` in `artifacts.ts`. - `ensure` revokes the day-long write token that creating a repo returns. - Tokens last an hour by default. - `moveBranch` mints a five-minute write token and sends git's own ref update over smart HTTP (`src/lib/game-repo/smart-http.ts`), the same request `git push --force <remote> <sha>:<branch>` makes, because the server has no `git` binary. - No route deletes a repo. ## Forks A fork is a player's own copy of someone else's game, used by [local developer mode](https://docs.moonspell.app/moonspell/local-dev-mode.md#someone-elses-game). The host interface covers it: | Function | What it does | | --- | --- | | `forkRepoName(game, userId)` | The fork's name: `<repo name>-fork-<12 hex>` | | `createFork(game, userId, seed)` | On Artifacts, the binding's `fork` with the default branch only. On GitHub, a new repo seeded by a copy, since GitHub cannot fork a repo into the organization that owns it | | `copyCommit(from, to, sha, branch, {expected})` | Copies a commit from one repo onto another's branch over git smart HTTP, as a compare-and-swap, with no git binary | | `branchHead(game, branch)` | The branch's current commit | | `isAncestor(game, a, b)` | Whether one commit is in another's history. GitHub: the compare API. Artifacts: the edge walks every parent, at most 3,000 commits | On Artifacts the server reaches these through two more internal edge routes: ```text POST /internal/repos/ancestor {name, ancestor, sha} -> {ancestor, complete} POST /internal/repos/fork {name, target, description?} -> {name, remote, defaultBranch, created} ``` ## Tests `npm test` runs `src/lib/game-repo/game-repo.test.ts`: the edge Worker on Miniflare with Cloudflare's local Artifacts simulator, and the server's Artifacts host calling it. It covers creating a repo twice, pushing with the run script's own push command (a 12 MB model included), cloning with a read token and failing to push with it, a draft branch, a forced push, a merge onto the public branch, reading a file, `commitExists`, `moveBranch`, the move script's mirror, and the edge refusing bad requests. The GitHub host is tested with GitHub's API stubbed. The Artifacts tests need `cd edge && npm install`; without it they are skipped. ## Moving existing games `scripts/move-repos-to-artifacts.mts --game <id> | --all [--dry-run]`. For each game on GitHub it makes `<namespace>/<same name>`, mirrors every branch and tag, checks that the public head and every version's `commit_sha` are on the new repo and that nothing moved meanwhile, then sets `games.repo` and `games.repo_host = 'artifacts'`. A game with a run in progress is skipped. It is safe to run again, and the GitHub repo is left in place. A developer working on a moved game in local developer mode gets the new remote from their next repo token. ## Switching a deployment to Artifacts 1. Pick a Cloudflare account on Workers Paid and a namespace name. 2. In `edge/wrangler.jsonc`, enable the `artifacts` binding with that namespace, run `npx wrangler types`, and deploy the edge with `INTERNAL_API_SECRET` set to the server's value. 3. Apply `db/migrations/0016_game_repo_host.sql` (`npm run db:migrate`). It is additive. 4. Set `GAMES_ARTIFACTS_NAMESPACE` on the server, with `CRESCENT_EDGE_URL` and `INTERNAL_API_SECRET`, and deploy. Nothing changes yet. 5. Move games: a dry run, then one game, then `--all`. 6. Set `GAMES_REPO_HOST=artifacts` so new games are made there. To go back, unset `GAMES_REPO_HOST`: new games go to GitHub again. Moved games stay on Artifacts unless their `games.repo` and `repo_host` are set back by hand; their GitHub repos still have everything up to the move. --- # Local developer mode Source: https://docs.moonspell.app/moonspell/local-dev-mode > The server side of local developer mode - how the Moonspell server signs a developer's CLI in, gives it the game's repo, takes a published version and handles invites, under /api/dev. In local developer mode a developer builds a Moonspell game on their own machine with their own coding agent, and the `crescent` CLI publishes it to the Moonspell app. Moonspell runs no sandbox and no model for it. This page is the server side: the routes, the sign-in, the rules. The developer's side is [Publish to the Moonspell app](https://docs.moonspell.app/start/publish-to-moonspell.md). The CLI itself is handed out built; its source is private ([What is open and what is private](https://docs.moonspell.app/start/open-source.md)). Everything here is behind `CRESCENT_DEV_MODE=on`. When it is off (the default), every `/api/dev/*` route answers 404 `{error: "dev_mode_off"}`. The tables come from `db/migrations/0015_local_dev_mode.sql`. A local game is a game like any other: owned by the account that signed the CLI in, listed in that player's games on every app, and **public at once**. Its first version goes into the feed for every player, plays from its `/g/<buildId>` link and the App Clip, and is open to community builds, like a game made in the app. `games.source` and `game_builds.source` (`worker` or `local`) only record where a game started and where each version came from. It works the other way round too: every game is its owner's dev game, wherever it was made. A game made in the app can be continued and published from the owner's machine, and from a collaborator's (`crescent continue <gameId>`). One rule between the two: a publish waits while a worker run is building the game (409). ## Code | What | Where | | --- | --- | | Routes | `src/app/api/dev/**` | | The error shape, the switch, body parsing | `src/lib/dev/http.ts` (`devRoute`, `DevError`, `devModeOn`) | | Every limit | `src/lib/dev/limits.ts` | | Tokens, user codes, invite tokens | `src/lib/dev/tokens.ts` | | Who is calling, the device flow, CLI tokens | `src/lib/dev/auth.ts` (`devCallerFromRequest`, `requireDevCaller`) | | Roles | `src/lib/dev/access.ts` (`roleFor`, `may`, `devGameFor`) | | `DevGame`, `DevVersion` | `src/lib/dev/shapes.ts` | | Local games | `src/lib/dev/games.ts` | | The game's repo | `src/lib/dev/repo.ts` | | Version rules, pure | `src/lib/dev/upload-rules.ts` | | Begin and complete a version | `src/lib/dev/uploads.ts` | | Invites and collaborators | `src/lib/dev/invites.ts` | | The cover | `src/lib/dev/cover.ts` | | Someone else's game: access, the fork, drafts (rules pure in `fork-rules.ts`) | `src/lib/dev/fork.ts`, `src/lib/dev/fork-rules.ts` | | The starter and CLI downloads | `src/app/api/dev/starter/`, `src/app/api/dev/cli/` | Unit tests: `src/lib/dev/*.test.ts` (`npm test`). ## Downloads These need no sign-in. | Route | Answer | | --- | --- | | `GET /api/dev/starter` | `{url, version, sha256, size}`: the current starter archive. Its files sit at the archive's root. | | `GET /api/dev/cli` | `{url, version, sha256, size, blobUrl}`: the current CLI package. `url` is a versioned URL on moonspell.app that npm and npx take. | | `GET /api/dev/cli/latest.tgz` | Redirects to the current CLI package (cached for a minute), for `npx https://moonspell.app/api/dev/cli/latest.tgz init my-game`. | | `GET /api/dev/cli/crescent-cli-<version>.tgz` | Redirects to that version's package. A version's bytes never change. | The starter is the template's tracked files with the overlay in `templates/crescent-game-local/` laid over them: `rewrites.md` (exact rewordings of the skills and docs that name the hosted builder's sandbox) and `files/` (the `moonspell-dev` skill, `.claude/settings.json`, the save hook). `scripts/pack-starter.mts` packs it, and fails when a rewording no longer matches the template or a file still names something only the hosted builder has. The template itself is never changed for this mode. Packing also builds the CLI, so it needs the CLI's private source. ## Who is calling Every other route takes `Authorization: Bearer <token>` with either: - a **dev token**, which `crescent login` gets through the device flow below. Only its SHA-256 is stored; revoking it marks it revoked. - the **app session** (`src/lib/app-session.ts`), which the web app sends, so the developer pages on the web call the same routes. Making or changing anything needs a registered account (signed in with Google, or Apple on iOS): an anonymous one gets 403 `sign_in_required`. ## Errors Every answer is JSON with `Cache-Control: no-store`, and every success is 200. Errors are `{error: "<code>", message}`, where `message` is a sentence for the developer: | Code | Status | When | | --- | --- | --- | | `unauthorized` | 401 | No credentials, a revoked token, or the wrong kind for the route | | `sign_in_required` | 403 | An anonymous account | | `forbidden` | 403 | A collaborator asking for an owner's action | | `not_found` | 404 | No such thing, or not the caller's | | `dev_mode_off` | 404 | `CRESCENT_DEV_MODE` is not on | | `limit` | 429 | Too big, too many files, too many versions or games a day | | `invalid` | 400 | A malformed body, a bad path, a build that will not play | | `conflict` | 409 | The commit is not pushed, the upload expired, a worker run is building the game, a version number was taken | | `authorization_pending`, `slow_down`, `access_denied`, `expired_token` | 400 | The device flow's poll | An `invalid` answer from completing a version also carries `problems: string[]`. ## Sign-in: the device flow It follows the shape of the OAuth device flow (RFC 8628): 1. `POST /api/dev/device` (no auth) `{clientName?}` returns `{deviceCode, userCode, verificationUri, verificationUriComplete, interval: 3, expiresIn: 600}`. `userCode` looks like `ABCD-EFGH` and avoids letters and digits that are easy to confuse. `verificationUri` is `<PUBLIC_BASE_URL>/cli`. 2. The person opens `/cli?code=...` signed in, sees which client is asking, and approves (`GET /api/dev/device/:userCode`, `POST /api/dev/device/approve`). 3. The CLI polls `POST /api/dev/device/token` (no auth) `{deviceCode}` every 3 seconds: `authorization_pending` until approved, `slow_down` when it polls too fast, `access_denied` once declined, `expired_token` for an unknown, expired or used code. Once approved it gets `{token, user: {id, name}}`. One code gives one token. Then: | Route | Auth | Answer | | --- | --- | --- | | `POST /api/dev/handoff` | dev token | `{code, expiresIn: 300}`: a one-time code that signs the CLI in as the same account (`crescent login --code`). The Moonspell MCP server hands this to the machine. | | `POST /api/dev/handoff/token` | none | `{code}` returns `{token, user}` once. | | `GET /api/dev/me` | dev token | `{user: {id, name}, tokenName, createdAt}` | | `POST /api/dev/logout` | dev token | Revokes it. | | `GET /api/dev/tokens` | app session | The account's live tokens, newest first (Settings) | | `DELETE /api/dev/tokens/:id` | app session | Revokes one. | ## Games | Route | Who | Body and answer | | --- | --- | --- | | `POST /api/dev/games` | registered | `{title}` returns `{game: DevGame}`: a `local` game owned by the caller. At most 20 a day per account. | | `GET /api/dev/games` | signed in | `{games: DevGame[]}`: the caller's games, made in the app or on a machine, and the ones they collaborate on, most recently touched first | | `GET /api/dev/games/:id` | owner, collaborator | `{game, versions, collaborators}`: versions newest first (at most 50) | | `POST /api/dev/games/:id/repo-token` | owner, collaborator | `{remoteUrl, username, password, branch: "main", expiresAt}`; creates the repo on the first call | ```ts DevGame = { id, title, slug, repo, role: "owner" | "collaborator", coverUrl, devUrl, latest: DevVersion | null } DevVersion = { buildId, version, createdAt, summary, commitSha, by: { id, name }, shareUrl, platforms: { ios: true, android: boolean, web: boolean } } ``` `devUrl` is `<PUBLIC_BASE_URL>/dev/<id>` and `shareUrl` is `<PUBLIC_BASE_URL>/g/<buildId>`. Anyone without a role on a game gets `not_found`. The repo is the game's own private repo, the same one worker runs push to ([Game repos](https://docs.moonspell.app/moonspell/game-repos.md)). The answer is host-neutral (`remoteUrl`, `username`, `password`), so the CLI never needs to know where repos live. The password is a short-lived token for that one repo; the CLI hands it to git per command and does not write it to disk. ## Versions A version's files go from the developer's machine straight to blob storage, one client token per file; the server never proxies them. The layout is the same as for worker runs: `games/<id>/v<n>/` for iOS, with `android/` and `web/` beside it ([Builds and versions](https://docs.moonspell.app/moonspell/builds.md)). 1. **Begin**: `POST /api/dev/games/:id/versions` (owner, collaborator) with `{commitSha, summary, platforms, files}`. The server checks the limits, that the repo has the commit (409 until it is pushed), and that no worker run is building the game (409). It then reserves the next version number and answers `{uploadId, version, expiresAt, uploads}`, one upload token per declared file. 2. **Upload**: the CLI puts each file with its token. 3. **Complete**: `POST /api/dev/games/:id/versions/:uploadId/complete` (whoever began it) with `{gdd?}`. The server checks that every declared file arrived at its declared size and that each platform's build is complete and refers only to files of this version. It then records the version, and from that moment it is public: in the owner's games, the feed, at `/g/<buildId>`, in the App Clip and the Android and web apps. It answers `{version, shareUrl, devUrl, warnings}`; completing again answers with the same version. iOS must pass. Android and the web are best effort, as for worker runs: a platform that fails its checks is left out of the version, and `warnings` says why. If the game has no cover yet, the cover workflow starts once, from the version's notes. ### Limits - `commitSha` is a full 40-character hash; `summary` at most 4,000 characters; `gdd` at most 30,000. - `platforms` includes `ios`, and each file belongs to one listed platform. - A path is relative, with forward slashes, no empty, `.` or `..` segment, at most 300 characters of letters, digits and `. _ - @ + ~`. - At most 60 MB a file, 2,000 files and 200 MB a version, 30 versions a game in 24 hours, and an upload lasts 30 minutes. An abandoned upload holds its version number until it expires; if a later version completes meanwhile, that number is skipped. Worker runs step over live reservations, so the two never take the same number. ## Invites and collaborators | Route | Who | Body and answer | | --- | --- | --- | | `POST /api/dev/games/:id/invites` | `play`: owner, collaborator; `build`: owner | `{kind, maxUses?, expiresInDays?}` returns `{invite}` | | `GET /api/dev/games/:id/invites` | owner (all), collaborator (their own) | `{invites}`, live ones, newest first | | `DELETE /api/dev/invites/:id` | the game's owner or the invite's maker | Revokes it. | | `GET /api/dev/invites/:token` | none | The invite's kind, the game, its latest version, and whether it still works | | `POST /api/dev/invites/:token/accept` | registered | `build` only: the caller becomes a collaborator. Accepting again, or as the owner, changes nothing. | | `DELETE /api/dev/games/:id/collaborators/:userId` | owner; a collaborator for themselves | Removes them. | An invite's link is `<PUBLIC_BASE_URL>/i/<token>`. A `build` invite defaults to one use within 7 days; `maxUses` goes up to 1,000 and `expiresInDays` to 365. A play invite is not used up by being opened. A collaborator may read the game, get a repo token, publish versions and make play invites, but not make build invites or remove other people. Signing in from an anonymous account to an existing one (`moveEverything`, `src/lib/sign-in.ts`) carries tokens, games and collaborations over. ## Someone else's game Any registered player may continue a game they don't own, as long as it takes other players' changes (`PublicGame.buildable`: a repo with a commit on its latest version, and community runs open). They work on their own copy, publish drafts only they play, and merge through the same merge run and judge as the app's merge card ([Community builds](https://docs.moonspell.app/moonspell/community-builds.md)). **The rule that shapes it: a contributor's credentials never write the game's repo**, neither its public branch nor anyone's draft. A token for one shared repo can't promise that (GitHub's and Artifacts' tokens are per repo, not per branch), so: - **The fork is a repo of its own**, one per game and player (the `game_forks` table): `<game repo name>-fork-<12 hex of the player's id>`, private, on the same repo host as the game, holding the game's public branch (or the player's unmerged draft, when they have one) on `main`, never the game's other branches. On GitHub it is a new repo in the same organization seeded by a copy (GitHub can't fork a repo into the org that owns it); on Artifacts it is the binding's own `fork(name, {defaultBranchOnly: true})`, then the same copy when the seed is a draft. The player's machine only ever gets a write credential for that repo. - **The draft stays where every draft is**, `community/<userId>` on the game's repo, where Nova's draft runs, the merge run and the judge already read it. Only the server moves that branch: when a draft version completes, it copies the fork's commit over plain git smart HTTP (upload-pack from the fork with a read token, receive-pack to the game's repo with a write token, both minted server-side and used once: `copyCommit`, `src/lib/game-repo/smart-http.ts`). No git binary on Vercel, no change to the merge run. Why not let the merge run fetch the fork instead: Nova's draft runs continue `community/<userId>` and decide whether a draft starts over from what is there, and cancelling and the judge read it too. One home for the draft means none of them changes. ### Who is who `GET /api/dev/games/:id/access` (any signed-in caller, anonymous included) → `{role, buildable, reason?, game: {id, title, slug}, draft, forked}`: | `role` | Who | Works on | |---|---|---| | `owner`, `collaborator` | the game's own people | its repo (`repo-token`), as before | | `contributor` | any registered player, on a `buildable` game | their fork (`fork`) | | `none` | an anonymous account, or a game that can't take changes (`reason` says which) | nothing | `draft` is a contributor's unmerged draft `{buildId, draft, baseVersion, commitSha, summary, createdAt}` (made with Nova or on their machine), the one a merge would put in. `continueRoleFor` (`fork-rules.ts`) decides; the web's `continueRole` and the CLI's `continueAccess` follow it. ### Routes | Route | Who | Body → answer | |---|---|---| | `POST /api/dev/games/:id/fork` | contributor | → `{remoteUrl, username, password, branch: "main", expiresAt, draft: {branch: "moonspell/draft", commitSha} \| null}`: an hour of write access to their fork, made on the first call (that call counts toward the day's community changes). `draft`: the player's draft as the game's repo has it, copied onto the fork's `moonspell/draft` on every call, so their machine can merge what Nova changed in the app. | | `POST /api/dev/games/:id/versions` | contributor | the version body plus `draft: true` → `{uploadId, draft: true, expiresAt, uploads}`. The commit must be on their fork (409 until pushed); no Nova run on their draft and no merge of it may be running (409); the day's limit (429); nothing on their draft branch may be missing from the commit (409 `reason: "draft_changed"`, below). Files go to `games/<id>/drafts/community-<userId>/local-<uploadId>/`. | | `POST /api/dev/games/:id/versions/:uploadId/complete` | contributor | `{gdd?, draft: true}` → `{draft: {buildId, draft, baseVersion, commitSha, summary, createdAt, platforms}, warnings, notice}`. Files and manifests are checked as for a version; then `community/<userId>` moves to the commit (compare-and-swap on its old value); then the draft build is recorded in `recordBuild`'s draft shape (`branch`, the next `draft`, `base_version`, `version` null, `source: 'local'`). From then on only that player plays it, in the feed and on Home in place of the public version, as with Nova's drafts. | | `POST /api/dev/games/:id/merge` | contributor | `{confirmed: true}` (the app's tick, never implied: anything else is 400) → `{run, message}`. Calls `startMerge` and starts the merge run, as `POST /api/app/games/:id/merge` does. Refused while a draft from their machine is still uploading. | An owner or collaborator sending `draft: true`, or asking to merge, gets 400 `invalid` (their publish makes the next public version). A `none` caller gets 403 `forbidden` with the reason. ### Drafts from two places A player may change their draft with Nova in the app and on their machine. Neither loses the other's commits: - **Machine, then Nova:** the draft branch has the machine's published draft, and Nova's next draft run continues it (it has an unmerged draft, so it doesn't start over) and pushes a fast-forward. - **Nova, then machine:** a draft from the fork moves `community/<userId>` only when nothing on it would be lost (`draftMove`): the branch doesn't exist (`create`); it is already there (`none`); nothing on it is unmerged (`start-over`, forced, as a draft run started over does); or its head is in the commit's history (`forward`, checked on the fork: GitHub's compare API, or the edge walking every parent on Artifacts). Otherwise it refuses (`refuse`: 409 `draft_changed`, with `draftBranch` and `draftCommit`) and puts the draft on the fork's `moonspell/draft`. `crescent save` merges `moonspell/draft` before every push (a conflict stops it and says how to merge by hand), so the next publish goes forward. - A publish refuses while a Nova run is changing the draft or a merge of it is running; a merge refuses while a draft from the machine is still uploading. Saved-but-unpublished commits stay on the fork: a merge takes the last published draft (`crescent merge` says so when the folder has newer commits). ### Limits The community rules hold: `buildable` games only, a registered account, and the day's limit of 5 community changes per player and game (`contributionBlock`, `contributionsToday`: Nova's draft runs and drafts from a machine together, counted when the fork is first made and at each draft publish). The author can cancel an accepted change as for any other. ### On the machine `crescent continue <gameId>` asks `access` first. For a contributor it calls `fork`, clones it, lays the local overlay, and writes `.moonspell/game.json` with `fork: true`. The marker never reaches a commit: the file is committed without it first when the game has none, and git is told to leave the local change out (`update-index --skip-worktree`), so a merged draft never makes the game's own repo look like a fork (and `continue`/`link` for the game's own people drop a stray marker). `save` pushes to the fork, merging `moonspell/draft` first; `publish` makes a draft and says that only they play it until they merge it (no share link, no QR code); `crescent merge --confirm` (MCP `moonspell_merge {confirmed: true}`) asks for the merge, and says a judge decides whether it becomes the game's next version. The remote MCP server's `moonspell_start` hands out the same `continue` command for other people's buildable games. ## Not done yet - No live "new version" event: a local version has no worker run, so open apps see it on their next fetch of the games list or the feed. - A game backend deployed from the developer's machine, and generated sounds, sprites, models or a new cover on demand. - A share link for a draft. A draft plays only for its player, signed in, in the app and on moonspell.app. --- # The Moonspell MCP server Source: https://docs.moonspell.app/moonspell/mcp-server > How the remote MCP server at mcp.moonspell.app works - what it serves a developer's agent, how signing in with Google turns into a dev token, and how to run and test it locally from mcp/. `https://mcp.moonspell.app/mcp` is a remote MCP server for a developer's own coding agent (Claude Code, Claude.ai, Cursor, Codex or any MCP client). It is the front door of [local developer mode](https://docs.moonspell.app/moonspell/local-dev-mode.md): the person signs in with Google, and the agent gets the engine's skills and docs, the person's games, share links with a QR code, invites, and the commands that set a game folder up on their computer. How to add it to an agent, and how it differs from the public docs server, is on [MCP servers](https://docs.moonspell.app/ai/mcp.md). A remote server cannot touch the developer's files, so everything that needs the folder (save, publish, sync the engine, continue a game, link a folder) stays with the `crescent` CLI and its local stdio server (`crescent mcp`). This server tells the agent which command to run, and hands the CLI a one-time sign-in code so the person does not sign in twice. The code is in `mcp/`: its own Cloudflare Worker, `moonspell-mcp`, with its own `package.json`. The one route it needs on the Moonspell server is `POST /api/internal/dev-session` (`src/app/api/internal/dev-session/`). ## What it offers **Instructions**, sent at initialize: what Moonspell is, that a game is a folder made from the starter with the engine's skills, which skills to load first (game-design-doc, then game-router for a new game), to save after every change, that publishing is public at once, and that local work goes through the `crescent` CLI. **Tools.** Where a tool has a twin in `crescent mcp`, it has the same name; the remote ones take a `gameId` where the local ones read the game from the folder. | Tool | What it does | | --- | --- | | `moonspell_status` | Who is signed in through this connection, and which starter the skills and docs come from | | `moonspell_games` | The games this account owns or collaborates on, with the latest share link and the developer page | | `moonspell_game` `{gameId}` | One game: its versions, the people building it, its developer page | | `moonspell_create_game` `{title}` | Registers a new game (no folder yet; `moonspell_start` makes it) | | `moonspell_links` `{gameId}` | The latest version's share link, its QR code as text and as a PNG image, and the developer page | | `moonspell_invite` `{gameId, kind, maxUses?, expiresInDays?}` | An invite link: `play` or `build` (owner only) | | `moonspell_start` `{gameId?, dir?, title?}` | The commands that set up a folder on this computer, signed in with a one-time code | | `moonspell_skills` | Every engine skill with what it is for | | `moonspell_skill` `{name}` | One skill's `SKILL.md` and the paths of its other files | | `moonspell_skill_file` `{name, path}` | One of those files (references, recipes, the backend scaffold) | | `moonspell_engine_docs` `{topic?}` | The template's `docs/` and its rules; without a topic, the list | **Resources**: `moonspell://skills/<name>` and `moonspell://docs/<topic>`. The skills are also served through the MCP Skills extension as `skill://moonspell/<skill>/SKILL.md`, so a client that supports it loads them as skills without tool calls. **Prompts**: `new_game {idea}` and `change_game {request}`: load the right skills first, build the smallest playable version, save after every change, tell the developer before publishing. **Where the skills come from.** `mcp/scripts/bundle.mts` runs before every `wrangler dev` and `wrangler deploy` (the `build` command in `mcp/wrangler.jsonc`). It reads the template's tracked `.claude/skills/`, `docs/` and `CLAUDE.md` and lays `templates/crescent-game-local/` over them the way `scripts/pack-starter.mts` does, so the server serves the starter's wording. A change to the rewording in `scripts/pack-starter.mts` needs the same change in `bundle.mts`. Every skill and doc answer ends with the starter version it came from. Inside a game folder, the folder's own `.claude/skills` win. ## How signing in works One Worker plays both OAuth roles, the authorization server for MCP clients and the MCP resource they call, with `@cloudflare/workers-oauth-provider`'s `OAuthProvider`. The provider serves the discovery documents (`/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource/mcp`), the token endpoint, dynamic client registration and Client ID Metadata Documents. The Worker's own code (`mcp/src/auth.ts`) is the consent page and the Google step: 1. `GET /authorize` validates the client, its exact redirect URI, the resource and PKCE, then shows a consent page: the app's name (marked as not verified for a self-registered client), where its access goes, and what it will be able to do. 2. `POST /authorize` records the approval and redirects to Google, with its own PKCE and a nonce. 3. `GET /callback` exchanges Google's code for an ID token and checks it. The Worker sends the ID token to `POST /api/internal/dev-session` on the Moonspell server, which verifies it again, finds or makes the registered account, and mints a dev token named `MCP: <client name>`. The dev token is stored only in the grant's encrypted properties, and the client gets its code. On every MCP request, the grant gives the dev token, which is the bearer of every `/api/dev/*` call made for that person. Access tokens last an hour; a grant lives while the client keeps refreshing it and ends after 30 idle days. **Revoking the token in Settings ends the connection.** On a refresh the Worker checks the dev token, and a revoked one ends the grant; a tool call that gets a 401 ends the grant at once and says to reconnect. Security properties the end-to-end test checks: - Consent is asked per client before any redirect to Google, so one client cannot ride on another's approval. A remembered approval is opt-in, lasts 30 days and is bound to the client, its redirect URI and the resource. - The consent page cannot be framed, works once, and is bound to the browser; the approved request comes from storage, never from the form. - PKCE is S256 only. Redirect URIs must match exactly, and errors are redirected only once the client and its redirect URI are valid. - The dev token never appears in storage in the clear, in a URL, in a log line or in a tool's answer. - The Google ID token is verified by both the Worker and the Moonspell server, so the server never takes the Worker's word for who someone is. - The Worker has its own host name, so its OAuth endpoints never pass through the edge Worker. The main libraries are `agents` (`createMcpHandler`, a stateless MCP handler with no Durable Object), the MCP TypeScript SDK and `@cloudflare/workers-oauth-provider`. ## Local development and tests ```bash cd mcp && npm install npm test # bundle, unit tests, then the end-to-end test under wrangler dev --local npm run typecheck # bundle, then tsc npm run dev # wrangler dev --local on http://127.0.0.1:3231 (needs mcp/.dev.vars) ``` The end-to-end test (`mcp/test/e2e.test.ts`) starts the Worker and an in-memory stub of Google and of the Moonspell server (`mcp/test/fakes.ts`): no network, no real account, no database. It registers a client, signs in through the consent page, exchanges the code with PKCE, connects the MCP SDK's client, lists tools, resources and prompts, reads skills, creates a game, gets the hand-off commands, decodes the QR code, makes an invite, then checks the failure cases: a wrong PKCE verifier, a forged consent post, an unregistered redirect URI, denying, a replayed callback, an unverified email, refresh and revocation. For `npm run dev` against a real Google OAuth client, copy `mcp/.dev.vars.example` to `mcp/.dev.vars` and add `http://127.0.0.1:3231/callback` to that client's redirect URIs. --- # Contributing Source: https://docs.moonspell.app/contributing > How to contribute to Moonspell, what is in the public repo, which checks to run before a pull request, where plans and issues live, and the rules that keep every game playing everywhere. Moonspell is open source under the MIT License: the engine and game template, the examples, the skills, the server with Nova and the worker runs, the edge Worker, the contracts, the specs and these docs. The Moonspell phone app and the bundling CLI are private ([What is open and what is private](https://docs.moonspell.app/start/open-source.md)). Contributions to any public part are welcome. ## How to contribute 1. For anything bigger than a small fix, open an issue first and describe the change. It saves building something that cannot be merged. 2. Fork the repository and create a branch from `main`. 3. Keep the change focused: one fix or one feature per pull request. 4. Run the checks for every folder you touched (below) and say in the pull request what you ran. 5. A maintainer reviews it. The root `CONTRIBUTING.md` says the same in short. ## The public tree | Path | What it is | |---|---| | `templates/crescent-game/` | The engine (`src/engine/`), the starter game Sunset Sprint (`src/game/`), the example games (`examples/`), and the `CLAUDE.md`, `AGENTS.md`, `.claude/skills/` and `docs/` a coding agent reads. Every game repo starts as a copy of it. See [Engine overview](https://docs.moonspell.app/engine/overview.md) | | `agent/` | Nova, the chat agent (eve). See [Nova and the worker](https://docs.moonspell.app/moonspell/nova-and-the-worker.md) | | `src/workflows/` | The game-run and cover workflows | | `src/lib/` | Server logic: the sandbox and run script (`daytona.ts`), games, repos, realtime tokens, credits, community builds, sign-in | | `src/app/` | Next.js routes: the app API (`api/app/`), internal routes (`api/internal/`), the web app (`(app)/`), the shared-game page (`g/`), the game page (`w/`), well-known files | | `src/web/` | The web app's client code | | `edge/` | The Cloudflare Worker in front of the server. See [Live progress](https://docs.moonspell.app/moonspell/realtime.md) | | `db/` | Postgres migrations and `migrate.mjs` | | `contracts/`, `spec/` | What the three apps share, and every app behaviour with a column per app. See [Three apps, one product](https://docs.moonspell.app/moonspell/three-apps.md) | | `scripts/` | Operational scripts: the sandbox snapshot, starter upload, backfills, asset generation, playtests. Those that export games need the private CLI | | `playtests/` | Playtest scripts for older games that predate `playtest.json` | | `docs/`, `docs-site/` | These docs, and the Docusaurus site that renders them | | `.scratch/` | Plans and issues (below) | ## Checks before a pull request All of these are real scripts in the repo's `package.json` files. | You changed | Run | Where | |---|---|---| | The engine, the template, an example | `npm run typecheck`, `npm test`, `npm run export:web` | `templates/crescent-game/` | | The server, Nova, the workflows, the web app | `npm run typecheck`, `npm run lint`, `npm test`, `npm run build` | repo root | | The edge Worker | `npm run typecheck` (and `npm test` at the root, which runs its tests) | `edge/` | | The backend scaffold | `npm run typecheck`, `npm run smoke` against `wrangler dev` | the scaffold's `backend/` | | Contracts | `npm run contracts`, then `npm run contracts -- --check` | repo root | | Feature specs | `npm run parity` | repo root | | Docs | `npm --prefix docs-site run build` | repo root | A change to how a game looks or plays should also be played in a browser with `npm run web` in the template. More detail per part is in [Testing](https://docs.moonspell.app/moonspell/testing.md). ## Plans and issues Bug reports and ideas go in the repository's issues. Larger pieces of work are planned in Markdown under `.scratch/`: - one folder per feature, `.scratch/<feature-slug>/`; - the plan is `.scratch/<feature-slug>/PRD.md`; - issues are `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`; - each issue has a `Status:` line near the top: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`, or `done` with a line saying how it was verified. What is planned next for the engine is in the [Roadmap](https://docs.moonspell.app/contributing/roadmap.md). ## Rules **A game is written once and plays on the web, iOS and Android.** What differs per platform belongs in the engine (`*.web.ts` files, `Platform.OS` checks), never in a game and never in one app. If a game needs a platform workaround, the fix goes into the engine so every game gets it. See [Platform layer](https://docs.moonspell.app/engine/platform-layer.md). **The template is the production template.** The hosted Moonspell worker makes every new game from `templates/crescent-game/`, and on each run it copies the template's engine, skills, docs, examples, `CLAUDE.md` and package files into existing games, for every file their builder never changed. A change there reaches every game on its next build. So: - keep changes additive and backwards compatible; - do not rename or remove scripts, skills, files or exports that games may use; - do not change engine behaviour that games rely on without a plan in `.scratch/`; - keep the template's own docs and skills in step with the code they describe. **Game data lives on the game's own backend.** Leaderboards, rooms and saves use the backend scaffold in `templates/crescent-game/.claude/skills/realtime-backend/scaffold/backend/`, never a third-party data service. See [Backend overview](https://docs.moonspell.app/backend/overview.md). **The app's native surface is fixed.** A game cannot add native code: the template's `package.json` pins the native modules the Moonspell app ships. Pure JavaScript packages are fine. **Generated files are never edited by hand.** For example `src/web/generated/`: change `contracts/` and run `npm run contracts`. **Docs are true or they are not written.** Every name, prop, command and path on a page matches the source. Code samples come from, or are checked against, `templates/crescent-game/src` and `examples/`. Pages are MDX: keep placeholders and braces inside code. Short sentences, plain words, no emoji. **Design is monochrome.** Black, white and grays, in the docs, the web app and the engine's default UI. --- # Roadmap Source: https://docs.moonspell.app/contributing/roadmap > Which engine foundations and genre modules are built, which come next, and the phone-first principles that set the order. The engine grows in two layers. **Foundations** are shared pieces every genre stands on (a character controller, bots, combat, saves, a 2D layer). **Genre modules** are complete game kits on top of them, each with a `defineXGame(config)` entry point. This page lists what is built, what comes next, and why in that order. The modules themselves are documented in [Game modules](https://docs.moonspell.app/modules/overview.md), and how to add one in [Writing a module](https://docs.moonspell.app/modules/writing-a-module.md). ## Principles Moonspell games are played on a phone, mostly in portrait, in short sessions, and spread by a share link. That sets the order: - **One thumb by default.** Unless a genre needs two thumbs (character games, racing), the first version uses one finger: tap, hold, swipe or drag. - **Portrait by default.** Landscape needs a two-handed grip. - **2D where the shape is 2D.** Many of the most played phone games are flat (block, merge and sort puzzles, cards, word games). They should not cost what a 3D game costs. - **Progression where it holds players.** Puzzles, idle games and runners with shops need coins, upgrades, streaks and saves; one-tap arcade games do not. - **Touch-first controls.** No dual-stick defaults and no free 3D camera; forgiving jumps (coyote time, jump buffer); large sticks with forgiving turn rates; a fixed camera for tap puzzles; an arc preview for flick and drag-to-aim games. - **Live by default.** Every game gets a live leaderboard and presence when it has a backend, and rooms where the genre fits. ## Built ### Foundations | Piece | What it gives a game | Docs | |---|---|---| | **F1** Character controller and cameras | A capsule character that collides with box levels, coyote time and jump buffer, third-person, first-person, top-down and side-view cameras, a stick plus look pad plus buttons in one gesture | [Character and cameras](https://docs.moonspell.app/engine/character-and-cameras.md) | | **F2** Combat kit | Health, armour, teams, hitscan, projectiles, explosions and melee, aim assist, statuses, kill credit and respawns, armed bots | [Combat](https://docs.moonspell.app/engine/combat.md) | | **F3** Bots | A nav grid with A*, steering, sight and hearing, roles and difficulties, bots that fill empty multiplayer seats | [Bots](https://docs.moonspell.app/engine/bots.md) | | **F4** Saves and progression | Typed, versioned save slots with migrations; currencies, a shop, unlocks, items, loot, XP, daily streaks, offline earnings, bests; synced to a per-player Durable Object on the game's backend | [Saves](https://docs.moonspell.app/engine/saves.md) | | **F5** Models and props, in part | Generated `.glb` models with fallbacks, animation clips, and `<Scenery>` for one model placed many times with instancing | [Engine overview](https://docs.moonspell.app/engine/overview.md) | | **F6** 2D layer | A Skia game canvas with stage units, pointer events, a fixed-step loop, tweens, a grid, collision, particles, a 2D camera, sprite sheets and tile maps, plus a 2D platformer controller | [2D](https://docs.moonspell.app/engine/2d.md) | Also built: the floating joystick, a story kit (dialogue, portraits, objectives), GPU grass, and the **game-router** skill, which turns a player's words into a genre and the route that builds it (see [Build with AI](https://docs.moonspell.app/ai/overview.md)). ### Genre modules and kits | Module | Shape | |---|---| | **Racing** | 3D arcade racing: track shape and length, cars, laps, AI rivals, time of day, music | | **Puzzle** | 2D puzzles on the 2D layer: block (place pieces, clear lines), merge, match (swap or tap to pop) and sort; endless or levels, a daily puzzle, every screen | | **Arena shooter** | Free-for-all, team deathmatch, gun game, capture the flag and king of the hill; rounds, maps, pickups, bots that play the objective, one phone hosting the match for friends who join from the link | | **Turns kit** | Turn-based board games: rules, a search, chess built in, a computer opponent that talks through the game backend's AI model, live online turns | | **Kingdom** | A story-led town builder: buildings on timers, crops, land to clear, a hero class, squads, joystick sorties against enemy camps, chapters of dialogue | Each has an example game in `templates/crescent-game/examples/` (see [Examples](https://docs.moonspell.app/examples.md)). ## Next In order: 1. **Runner module**, 3D on F1 and the level system: streamed track chunks, lane swipes, jump and slide, obstacles, coins, multiplier gates, a distance leaderboard and a ghost of the top run. Presets for lane runners, gate runners and obstacle courses. 2. **One-tap arcade kit**, 2D first: a fixed-step loop with simple physics (gravity, bounce, swing), a scrolling camera, and presets for stacking, flapping, rising, swerving and swinging games, so "a game where you tap to..." becomes a config. 3. **Idle and tycoon module** on F4: resources, producers, timers, offline earnings, upgrade tables, a daily streak. A 2D interface by default, with an optional top-down scene. 4. **Survivors and horde**: automatic attacks, waves, XP pickups, pick one of three on level-up. Needs the rest of F5: instanced crowds, so a hundred or more enemies stay cheap. 5. **Cards, and turns across days**: card games on the 2D layer and the turns kit, and asynchronous turns kept on the game's backend. ## Later Shapes that fit a phone and reuse what exists, not yet ordered: | Shape | Builds on | |---|---| | Eat-and-grow arena (.io style) with short rounds and bot fill | F3 bots, instanced crowds, rooms | | Flick sports (throw, shoot, bowl) with swipe-to-velocity and an arc preview | A small flick kit | | Round-based party games: lobby, roles, a round timer, votes, results; a draw-and-guess game | A rounds kit on rooms | | Tower defense | Bots on paths, the idle economy | | Daily word and trivia games with a shared seed and a share card | The 2D layer, F4 | | Obstacle courses with moving, crumbling and kill blocks | F1, the level kit | | Co-op escape and horror | F1, F3, light combat | | Drift and obstacle-course modes for racing | The racing module | ## Every module has the same shape - a `defineXGame(config)` entry point with presets; - a skill in `templates/crescent-game/.claude/skills/` that maps a player's words to the config, and a row in the **game-router** skill; - an example in `templates/crescent-game/examples/`; - a development-only debug line and an autopilot button, so a game can be checked without a human playing it; - the realtime default: a leaderboard and presence, and rooms where they fit. Plans for each piece of work live in `.scratch/<feature>/` ([Contributing](https://docs.moonspell.app/contributing.md#plans-and-issues)).