Platform differences
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.tsfile next to the default file, or aPlatform.OScheck inside an engine module. - Game code in
src/game/uses engine APIs that behave the same everywhere. It has noPlatform.OSchecks and no*.web.tsfiles. - 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.
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) |
| 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) | 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) |
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) |
| 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.wasmabout 8 MB, 16 MB for all ofdist/. - iOS bundle:
expo export --platform iosgave 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
resolutionScalefor a cheaper scene pass (Canvas and rendering). - 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-webgpuconfig plugin turns off (iOS).
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, Builds and versions).
See also
- Web, iOS, Android
- Platform layer
- Net for launch links and rooms