# Platform layer

> How one game source runs in a browser, on iOS and on Android, the shell contract (shell.ts, shellModule.ts, shellModule.web.ts), the web boot in src/engine/web, the engine's other platform branches, what happens with no Moonspell app around the game, and why platform differences never go in a game.

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.ts` file next to the default file or behind a `Platform.OS` check 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, `EngineCanvas` or `GameCanvas2D` for 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](https://docs.moonspell.app/engine/input.md)). 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:

```ts
// 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](https://docs.moonspell.app/engine/net.md)).

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:

1. It asks `navigator.gpu` for an adapter. If there is no `navigator.gpu`, or it returns no adapter, it shows "This browser can't run this game yet. Try the latest Chrome, Edge or Safari.", posts `unsupported` (`reason: 'webgpu'`) to the parent frame, and stops. The engine draws with WebGPU on every platform; there is no WebGL path.
2. It loads Skia's CanvasKit (`LoadSkiaWeb`), fetching `canvaskit.wasm` from the build's own folder (`webBase()`, the folder the page is served from).
3. It installs the system font fallback (below).
4. 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.
5. 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](https://docs.moonspell.app/platforms/web.md) 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; use `require()` 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 `Keyframe` to `entering`: in a browser it pins the element out of its layout. Use `rise`, `land` and `leave` from `@/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](https://docs.moonspell.app/start/open-source.md)).
