# Platform differences

> What differs between web, iOS and Android for a Moonspell engine game (rendering, input, audio, haptics, storage, fonts, safe areas, orientation, links, performance), where the engine handles each difference, and what the Moonspell app's shell adds when it is there.

One game source plays in a browser, on iOS and on Android. This page lists every difference between the three that a game author may notice, each with the engine file that handles it. Use it when something looks or behaves differently on one platform, and before you reach for a platform check in your game.

## The rule

Platform differences are handled in the engine, never in a game.

- Platform-specific code lives in `src/engine/`: a `*.web.ts` file next to the default file, or a `Platform.OS` check inside an engine module.
- Game code in `src/game/` uses engine APIs that behave the same everywhere. It has no `Platform.OS` checks and no `*.web.ts` files.
- A module that only exists on phones (haptics, sensors) is used so the game still plays without it.

If you find a difference that a game would have to work around, fix it in the engine. How the files are picked per platform is on [Platform layer](https://docs.moonspell.app/engine/platform-layer.md).

## Where the engine branches

The engine's whole platform layer, found by searching `src/engine` for `*.web.ts` files and `Platform.OS` / `Platform.select`:

| File | Branch |
| --- | --- |
| `index.web.ts` (template root) | The browser entry: `bootWeb()` instead of `registerRootComponent()` |
| `src/engine/web/boot.ts`, `web/systemFont.ts` | Loaded on the web only: WebGPU check, CanvasKit, Skia font fallback |
| `src/engine/shellModule.web.ts` | The shell is the framing page, over `postMessage` |
| `src/engine/input/keyboard.ts` | `Platform.OS !== 'web'`: no key listener on the phones |
| `src/engine/models/assetBytes.ts` | `Platform.OS === 'web'`: fetch the asset's URL; phones read the file |
| `src/engine/ui/Icon.tsx` | `Platform.OS === 'ios'`: SF Symbol; otherwise its Material Symbol twin |
| `src/engine/ui/motion.ts` | `Platform.OS === 'web'`: built-in Reanimated entrances instead of a `Keyframe` |
| `src/engine/hud/Gauge.tsx`, `modules/racing/RaceHud.tsx`, `2d/GameCanvas2D.tsx` | `Platform.select`: a named system font family on iOS, `sans-serif` elsewhere |

Everything else differs only because a library has a different build per platform (react-native-web, CanvasKit, MMKV's web build, and so on).

## Differences by area

| Area | Web | iOS | Android | Handled in |
| --- | --- | --- | --- | --- |
| 3D rendering | three.js `WebGPURenderer` on the browser's `navigator.gpu` | the same renderer on react-native-webgpu: Dawn on Metal | the same renderer on react-native-webgpu: Dawn on Vulkan | `render/EngineCanvas.tsx`; no WebGL path anywhere |
| No WebGPU (no `navigator.gpu`, or no adapter) | The page shows "This browser can't run this game yet. Try the latest Chrome, Edge or Safari." | Not applicable (react-native-webgpu ships Dawn) | Not applicable | `web/boot.ts` |
| Shadows | VSM | VSM | VSM | `Environment` sets VSM everywhere: the other filters lose the GPU device on react-native-webgpu's Metal backend ([Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md)) |
| Skia (HUD, 2D layer) | CanvasKit, `canvaskit.wasm` served next to the page | Skia's native build | Skia's native build | `web/boot.ts` loads CanvasKit before the game |
| Views | react-native-web (DOM) | React Native | React Native | Library builds |
| Touch | react-native-gesture-handler's web build; a mouse acts as one finger | Multi-touch | Multi-touch | `input/` |
| Keyboard | `TouchControls`, `Joystick` and `CharacterControls` also read keys ([Input](https://docs.moonspell.app/engine/input.md)) | No listener | No listener | `input/keyboard.ts`, `input/keyMap.ts` |
| Two-finger pinch | In a frame only: read as closing the game and reported to the framing page | Belongs to the game | Belongs to the game | `shellModule.web.ts` |
| Mixer audio | react-native-audio-api's web build on the browser's `AudioContext`. Browsers hold audio until a user gesture; the mixer resumes on the first tap, click or key | react-native-audio-api | react-native-audio-api | `audio/mixer.ts` |
| `RaceAudio`, 2D one-shots | `expo-audio`'s web build (HTML audio elements) | `expo-audio`, set to play with the silent switch on and mix with other apps | `expo-audio`, same settings | `audio/RaceAudio.ts`, `2d/sound.ts` (`setAudioModeAsync({ playsInSilentMode: true, interruptionMode: 'mixWithOthers' })`) |
| Haptics | `expo-haptics`' web build: the Vibration API where the browser has one, a hidden switch control on iOS Safari, nothing on a desktop | `expo-haptics` | `expo-haptics` | `2d/haptics.ts`, `ui/PressScale.tsx`, `feel/FeelOverlay.tsx`: failures are caught, so a device without haptics is never an error |
| On-device data | MMKV's web build on `localStorage`, so it is per browser origin | MMKV | MMKV | `net/storage.ts`; an in-memory store if MMKV cannot load |
| Shared player store | `crescent.player` is shared by every game served from the same origin | shared by every engine game in the same app (in the Moonspell app, all of them) | same as iOS | `net/storage.ts`; each game also gets `crescent.game.<GAME_ID>` ([Saves](https://docs.moonspell.app/engine/saves.md)) |
| Asset bytes (`.glb`, audio) | `fetch()` of the asset's URL | `expo-asset` then `expo-file-system` | same as iOS | `models/assetBytes.ts` |
| Fonts loaded with `useGameFonts` | `expo-font` | `expo-font` | `expo-font` | `ui/useGameFonts.ts` |
| Skia `matchFont()` | Always the engine's Nunito (CanvasKit has no system fonts) | The system family the engine names (`Helvetica Neue`, `Avenir Next`) | `sans-serif` | `web/systemFont.ts`, the `Platform.select` lines above |
| Icons | Material Symbol twin | SF Symbol | Material Symbol twin | `ui/Icon.tsx`, `ui/androidSymbols.ts` ([HUD and UI](https://docs.moonspell.app/engine/hud-and-ui.md)) |
| Entrance animations | Built-in Reanimated entrances | `Keyframe` | `Keyframe` | `ui/motion.ts` |
| Safe areas | `env(safe-area-inset-*)`, which is 0 unless the page sets `viewport-fit=cover`; the HUD's top row sits 14 px below the safe top | Insets from the device, read once at launch | Insets from the device, read once at launch | `core/insets.ts` / `core/insets.web.ts` (`getSafeInsets`, `useSafeInsets`, `hudTopInset`), used by the 2D layer, the puzzle screens and the HUD chrome; the HUD's top row stays at 54 points on phones |
| Status bar | No effect | Hidden by the template's `App.tsx` | Hidden by the template's `App.tsx` | `src/App.tsx` (`expo-status-bar`) |
| Orientation | Not locked; the page fills the window. A size change resizes the 3D renderer and camera in place | Portrait (`app.json`) | Portrait (`screenOrientation="portrait"`) | `app.json`, `render/EngineCanvas.tsx` |
| Launch and share links | `?launch=` and `?share=` on the page URL; `?room=` through `Linking` | From the Moonspell app; `null` in your own build | same as iOS | `net/launch.ts` (table below) |
| JavaScript engine | The browser's | Hermes | Hermes | Expo's defaults (`hermesEnabled=true` in the Android prebuild) |
| Canvas resolution | Layout size times `PixelRatio.get()` (the window's device pixel ratio) | Layout size times the screen scale | Layout size times the screen density | `render/EngineCanvas.tsx` |

## Performance notes

Nothing here was measured on phones; these are the facts we have.

- Web download: in one export of the default game, the JavaScript bundle was 5.4 MB and `canvaskit.wasm` about 8 MB, 16 MB for all of `dist/`.
- iOS bundle: `expo export --platform ios` gave a Hermes bytecode bundle of 8.3 MB for the same game.
- The 3D canvas draws at full device pixel ratio. Post-processing has a `resolutionScale` for a cheaper scene pass ([Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md)).
- react-native-webgpu's docs warn that Android emulators may use a software adapter (slow, some features missing) and recommend testing on physical devices. They also say Metal API validation on the iOS Simulator can cause false-positive crashes, which the `react-native-webgpu` config plugin turns off ([iOS](https://docs.moonspell.app/platforms/ios.md)).

## What the Moonspell app's shell adds

The shell is the app around the game. Inside the Moonspell app it is the optional native module `GenUXExpoModule`; in a browser, the page that frames the game. `src/engine/shell.ts` and `src/engine/net/launch.ts` read it through optional chaining, so a missing shell, or a missing field, is never an error.

| Engine API | Moonspell app | Your Expo build on a phone (no shell) | A browser tab, not framed | A browser, framed by your page |
| --- | --- | --- | --- | --- |
| `exitToShell()` | Back to the app's games | Does nothing | `history.back()` when there is history | Posts `exit` to the parent |
| `onShellClosed(listener)` | Fires when the app closes the game | Never fires | Never fires | Fires on a `closed` message |
| `isShellActive()`, `useShellActive()` | `false` while the app's feed keeps the game off screen | Always `true` | `true` unless the URL has `?active=0` | Follows `active` messages; starts from `?active=0` |
| `onShellActive(listener)` | Fires now, then on every change | Fires once with `true` | Fires once with the starting state | Fires now, then on every change |
| `getLaunchUrl()` | The link the game was opened with | `null` | The `?launch=` parameter | The `?launch=` parameter |
| `getShareUrl()`, `roomInviteUrl()` | The game's public link | `null` | From `?share=`, else `null` | From `?share=`, else `null` |
| `useLaunchRoomId()` | Room from the launch link | Falls back to `Linking` (needs a `scheme` in `app.json`) | Falls back to `Linking`, so `?room=CODE` works | Same as not framed |

In every case the template's `App.tsx` does the same thing on exit or close: it unmounts the game, which stops its sound, simulation and rendering, and shows a "Play" button that starts it again. While the game is not active, the engine pauses on its own: `EngineCanvas` stops its frame loop, `GameCanvas2D` stops stepping, and the audio mixer and `RaceAudio` go quiet.

Only the Moonspell app has its feed, share pages, App Clip and live updates. How it loads games is not part of the engine ([What is and is not open](https://docs.moonspell.app/start/open-source.md), [Builds and versions](https://docs.moonspell.app/moonspell/builds.md)).

## See also

- [Web](https://docs.moonspell.app/platforms/web.md), [iOS](https://docs.moonspell.app/platforms/ios.md), [Android](https://docs.moonspell.app/platforms/android.md)
- [Platform layer](https://docs.moonspell.app/engine/platform-layer.md)
- [Net](https://docs.moonspell.app/engine/net.md) for launch links and rooms
