How it works
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
- Expo starts the app from
index.ts(phones) orindex.web.ts(browser). - Both render
src/App.tsx, which renders your game'sComponent. - Your game puts an
EngineCanvason screen. It creates a three.jsWebGPURendereron a WebGPU canvas and runs a React Three Fiber root inside it. - R3F's frame loop calls every
useFramecallback, then three.js draws the scene, then the engine presents the frame. - 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.
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:
threeandthree/webgpuresolve tothree/build/three.webgpu.js,three/tsltothree/build/three.tsl.js, andthree/addons/*tothree/examples/jsm/*. There is exactly one three.js instance in the bundle, andWebGPURendererand the node materials are always available. A library that importsthreegets the same instance.@react-three/fiberresolves to its web build. The package's React Native entry targets expo-gl, which the engine does not use;EngineCanvasdrives the R3F reconciler itself..glb,.gltf,.binand.hdrare asset types, so 3D models load withrequire().
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.
How a frame is produced
EngineCanvas (src/engine/render/EngineCanvas.tsx) is the bridge between React Native and three.js:
- It renders a
Canvasfromreact-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'snavigator.gpu. - It waits for a layout size, then sets the canvas's backing size to the layout size times
PixelRatio.get(). - It creates an R3F root on that canvas with
createRoot. The root's renderer is an async factory:makeWebGPURenderer({ context })builds a three.jsWebGPURendererand awaitsrenderer.init(), so R3F waits for the GPU before the first frame. - It wraps the renderer's
render: run the engine's before-render hooks (camera shake), draw the scene (or letPostFXdraw it through its pipeline), run the after-render hooks, then callcontext.present()so the frame reaches the screen. - 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.
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), soonClick,onPointerDownand 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 |
| Physics | cannon-es | Pure JavaScript, stepped in a useFrame at priority -10, before your code. See Physics |
| Sound | react-native-audio-api, expo-audio | A mixer with buses and positional sound; it reads the camera through <AudioListener>. See Audio |
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 and The 2D layer.
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.
Where the platform differences live
A game never branches on the platform. The engine does, in two ways:
- Platform files. Metro picks
name.web.tsovername.tsin a browser build:index.web.ts,src/engine/shellModule.web.ts. The browser-only boot is insrc/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, and what a player notices on each platform is on Platform differences.
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.
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 |