# The 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
```
