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