Skip to main content

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

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).

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 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).
  • 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). 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 gameYou may
Should also run inside the Moonspell appAdd 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 buildsAlso 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:

AreaBudget
2D layer64 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 debrisA few hundred pieces for a crash, 1 to 5 thousand for weather; one draw call each
GPU particlesA few hundred for a local effect, 1 to 3 thousand for weather; one draw call each
RenderingShadow 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