The game contract
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.tsxdefault-exports aGameDefinition:id,title,description, an optionalbadgeandaccent, andComponent. Genre modules (defineRaceGameand the others) return one for you.src/App.tsxrendersgame.ComponentwithonExit. Keep that file and that contract.index.tsis the phone entry andindex.web.tsthe browser entry. Do not edit or deleteindex.web.ts: it loads Skia's CanvasKit before the game. Keep"main": "./index"inpackage.json.
Exit, close and clean up
- Show a way out. Every game has an exit or pause button that calls
onExit.TopBarandFinishCardhave one built in. - Unmounting stops the game.
App.tsxunmounts 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.tsxshows 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.
Orientation and layout
- Portrait by default. The template's
app.jsonsets"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).
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. - Do not use
Pressablefor 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,onPointerDownand 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). - 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 withrequire()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; userequire()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. threeis always the WebGPU build. Import the renderer and TSL fromthree/webgpuandthree/tsl.- Skia text uses
matchFont()or a bundled face withuseFont. In a browsermatchFontanswers with the engine's Nunito, so do not rely on a system font's exact metrics. - For entrance animations use
rise,landandleavefrom@/engine/ui, or Reanimated's built-in entrances (FadeIn,FadeInDown). Never pass anew Keyframe(...)toentering: 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). 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, GPU effects, The 2D layer, Canvas and rendering.
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:
<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.
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).
Check before you ship
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