Platform layer
A game is written once and plays in three places: a browser, an iOS app and an Android app. The engine carries every difference between them, in a thin layer of platform files and a few Platform.OS checks. A game never branches on the platform itself.
This page lists that layer: what it does on each platform, what the app around the game gives it (the shell), and what happens when there is no such app, as in a plain Expo project.
The rule
What differs per platform belongs in the engine, never in a game:
- Put platform-specific code in
src/engine/, as a*.web.tsfile next to the default file or behind aPlatform.OScheck inside an engine module. - Game code in
src/game/uses engine APIs that behave the same everywhere: the engine's controls (they answer to a keyboard in a browser),require()for assets,EngineCanvasorGameCanvas2Dfor drawing, the audio mixer, the UI kit's motion helpers. - A module that only exists on phones (haptics, sensors) must be optional: the game stays playable without it.
How files are picked per platform
Metro, through Expo's config, resolves platform extensions: when a web bundle imports ./shellModule, it takes shellModule.web.ts if it exists; the phones take shellModule.ts. The same applies to the entry: the template's package.json has "main": "./index", so the web build starts from index.web.ts and the phones from index.ts.
| File | Phones | Browser |
|---|---|---|
index.ts / index.web.ts (template root) | registerRootComponent(App) | bootWeb(() => require('./src/App').default) |
src/engine/shellModule.ts / .web.ts | An optional native module | The page that frames the game, over postMessage |
src/engine/web/boot.ts | Never loaded | Checks WebGPU, loads Skia's CanvasKit, installs fonts, starts the game |
src/engine/web/systemFont.ts | Never loaded | Answers Skia's matchFont() with bundled faces |
Inside engine modules, these branch at runtime:
| Where | What differs |
|---|---|
input/keyboard.ts | On the web, TouchControls, Joystick and CharacterControls also listen to the keyboard (Input). On the phones no listener is added |
models/assetBytes.ts | On the web an asset's bytes are fetched from its URL; on the phones they are read from the file through expo-asset and expo-file-system |
ui/Icon.tsx | iOS draws the SF Symbol; Android and the web draw its Material Symbol twin |
ui/motion.ts | On the web, rise and land use Reanimated's built-in entrances instead of a Keyframe |
hud/Gauge.tsx, 2d/GameCanvas2D.tsx | Platform.select picks a system font family name on iOS |
The shell contract
The shell is whatever app the game runs inside. On the phones that is the Moonspell app (or its App Clip); in a browser, the Moonspell web app that frames the game in an <iframe>. src/engine/shellModule.ts declares what a shell may give:
// Simplified from src/engine/shellModule.ts (the real type overloads addListener per event).
type ShellModule = {
exitToShell?: () => void;
isActive?: () => boolean;
addListener?: (event: 'onShellClosed' | 'onShellActive', listener: (event?: { active: boolean }) => void) => { remove: () => void };
launchURL?: string | null; // the link the game was opened with
shareURL?: string | null; // the game's public share link
};
Every field is optional, and the engine reads it with optional chaining, so a missing shell or a missing field is never an error. Games use it through @/engine:
Export (shell.ts) | What it does | With no shell |
|---|---|---|
exitToShell() | Leave the game and go back to the app's games list | Does nothing on the phones |
onShellClosed(listener) | The app closed the game; the template's App.tsx unmounts the game so its sound, simulation and rendering stop | Never fires |
isShellActive() | Whether the game is on screen (the app's feed keeps the next games loaded and paused) | true |
onShellActive(listener) | Fires right away with the current state, then on every change. Listeners must tolerate the same state twice | Fires once with true |
useShellActive() | isShellActive() as React state | true |
The engine already pauses itself while the game is not active: EngineCanvas stops its frame loop, GameCanvas2D stops stepping, and the audio mixer and RaceAudio go quiet. Use useShellActive() only for game code that runs outside those loops (your own timers, sounds outside the mixer). The launch and share links are read by the net module (getLaunchUrl, getShareUrl, useLaunchRoomId; see Net).
The template's App.tsx is where this is wired: it renders game.Component with an onExit that unmounts the game and calls exitToShell(), and subscribes to onShellClosed. When the game is not playing, it shows a "Play" button to start it again. Keep that contract in your game repo.
On the phones
shellModule.ts calls requireOptionalNativeModule('GenUXExpoModule') from expo. That native module is compiled into the Moonspell app and its App Clip only. In any other build, including a normal Expo development build of your game (npx expo run:ios, npx expo run:android), it is absent and the call returns null. The game then runs on its own: always active, never closed by a shell, and the exit button does nothing beyond what your game does in onExit. If your game is the whole app, make the exit button do something useful there (open a menu, restart), or hide it.
In a browser
shellModule.web.ts implements the same contract with postMessage to window.parent. Messages are plain objects tagged crescent: 'game' (from the game) or crescent: 'shell' (from the app), and only messages from the parent window are read.
| Direction | type | Meaning |
|---|---|---|
| game to app | ready | The game started |
| game to app | error (with message) | The game could not start |
| game to app | unsupported (with reason: 'webgpu') | No WebGPU in this browser |
| game to app | exit | The player left the game |
| game to app | pinch, pinch-end | A two-finger pinch (or trackpad pinch) is closing the game; scale goes from 1 to 0, exit says whether it ended past the threshold |
| app to game | closed | Stop the game |
| app to game | active (with active) | On screen or not |
The links come from the page's query string: ?launch=...&share=..., and ?active=0 starts the game paused.
Opened on its own, not in a frame (a plain expo start --web or a static export you host), the game is active (unless the URL has active=0), exitToShell() goes back one page in the browser history when there is one, no messages are sent, and the pinch watcher is not installed.
The web boot (src/engine/web/)
index.web.ts calls bootWeb(loadApp). In order:
- It asks
navigator.gpufor an adapter. If there is nonavigator.gpu, or it returns no adapter, it shows "This browser can't run this game yet. Try the latest Chrome, Edge or Safari.", postsunsupported(reason: 'webgpu') to the parent frame, and stops. The engine draws with WebGPU on every platform; there is no WebGL path. - It loads Skia's CanvasKit (
LoadSkiaWeb), fetchingcanvaskit.wasmfrom the build's own folder (webBase(), the folder the page is served from). - It installs the system font fallback (below).
- Only then it requires the game and registers it. Skia's web build reads CanvasKit the moment it is imported, so the game must not be evaluated earlier: this is why the entry uses
require()inside the callback and not a top-level import. - If any step throws, it shows "This game could not start. Reload to try again."
CanvasKit has no system fonts, and its matchFamilyStyle throws. systemFont.ts loads two of the engine's bundled faces (Nunito Bold, and Nunito Black from weight 800 up) and answers every matchFont() with them, so the engine's HUD, the 2D layer and any game that calls matchFont draw text in a browser with no change. A game that loads its own face with Skia's useFont gets exactly that face, as on the phones.
canvaskit.wasm must be served next to the page. In the template, the npm run web and npm run export:web scripts run setup-skia-web public first, which copies it into public/ (ignored by git). See Web for running and hosting a web build.
Gotchas
- No dynamic
import()in a game. The web bundle is one file so it can be served from any folder; userequire()or static imports. - Never build a file path or URL by hand. A web build names every asset by its address, the phones by a module id;
require()and the engine's loaders handle both. - Never pass a Reanimated
Keyframetoentering: in a browser it pins the element out of its layout. Userise,landandleavefrom@/engine/ui. - Skia text in a browser is Nunito unless you load a face yourself, so do not rely on a system font's exact metrics.
- Two-finger gestures belong to the game on the phones. In a framed browser game, a two-finger pinch where both fingers move toward each other is read as closing the game; two thumbs resting on controls are not.
- What the Moonspell app itself does with these messages (its feed, App Clips, share links) is part of the private app and is not documented here (What is and is not open).