Skip to main content

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

Where the engine branches​

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

FileBranch
index.web.ts (template root)The browser entry: bootWeb() instead of registerRootComponent()
src/engine/web/boot.ts, web/systemFont.tsLoaded on the web only: WebGPU check, CanvasKit, Skia font fallback
src/engine/shellModule.web.tsThe shell is the framing page, over postMessage
src/engine/input/keyboard.tsPlatform.OS !== 'web': no key listener on the phones
src/engine/models/assetBytes.tsPlatform.OS === 'web': fetch the asset's URL; phones read the file
src/engine/ui/Icon.tsxPlatform.OS === 'ios': SF Symbol; otherwise its Material Symbol twin
src/engine/ui/motion.tsPlatform.OS === 'web': built-in Reanimated entrances instead of a Keyframe
src/engine/hud/Gauge.tsx, modules/racing/RaceHud.tsx, 2d/GameCanvas2D.tsxPlatform.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​

AreaWebiOSAndroidHandled in
3D renderingthree.js WebGPURenderer on the browser's navigator.gputhe same renderer on react-native-webgpu: Dawn on Metalthe same renderer on react-native-webgpu: Dawn on Vulkanrender/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 applicableweb/boot.ts
ShadowsVSMVSMVSMEnvironment 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 pageSkia's native buildSkia's native buildweb/boot.ts loads CanvasKit before the game
Viewsreact-native-web (DOM)React NativeReact NativeLibrary builds
Touchreact-native-gesture-handler's web build; a mouse acts as one fingerMulti-touchMulti-touchinput/
KeyboardTouchControls, Joystick and CharacterControls also read keys (Input)No listenerNo listenerinput/keyboard.ts, input/keyMap.ts
Two-finger pinchIn a frame only: read as closing the game and reported to the framing pageBelongs to the gameBelongs to the gameshellModule.web.ts
Mixer audioreact-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 keyreact-native-audio-apireact-native-audio-apiaudio/mixer.ts
RaceAudio, 2D one-shotsexpo-audio's web build (HTML audio elements)expo-audio, set to play with the silent switch on and mix with other appsexpo-audio, same settingsaudio/RaceAudio.ts, 2d/sound.ts (setAudioModeAsync({ playsInSilentMode: true, interruptionMode: 'mixWithOthers' }))
Hapticsexpo-haptics' web build: the Vibration API where the browser has one, a hidden switch control on iOS Safari, nothing on a desktopexpo-hapticsexpo-haptics2d/haptics.ts, ui/PressScale.tsx, feel/FeelOverlay.tsx: failures are caught, so a device without haptics is never an error
On-device dataMMKV's web build on localStorage, so it is per browser originMMKVMMKVnet/storage.ts; an in-memory store if MMKV cannot load
Shared player storecrescent.player is shared by every game served from the same originshared by every engine game in the same app (in the Moonspell app, all of them)same as iOSnet/storage.ts; each game also gets crescent.game.<GAME_ID> (Saves)
Asset bytes (.glb, audio)fetch() of the asset's URLexpo-asset then expo-file-systemsame as iOSmodels/assetBytes.ts
Fonts loaded with useGameFontsexpo-fontexpo-fontexpo-fontui/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-serifweb/systemFont.ts, the Platform.select lines above
IconsMaterial Symbol twinSF SymbolMaterial Symbol twinui/Icon.tsx, ui/androidSymbols.ts (HUD and UI)
Entrance animationsBuilt-in Reanimated entrancesKeyframeKeyframeui/motion.ts
Safe areasenv(safe-area-inset-*), which is 0 unless the page sets viewport-fit=cover; the HUD's top row sits 14 px below the safe topInsets from the device, read once at launchInsets from the device, read once at launchcore/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 barNo effectHidden by the template's App.tsxHidden by the template's App.tsxsrc/App.tsx (expo-status-bar)
OrientationNot locked; the page fills the window. A size change resizes the 3D renderer and camera in placePortrait (app.json)Portrait (screenOrientation="portrait")app.json, render/EngineCanvas.tsx
Launch and share links?launch= and ?share= on the page URL; ?room= through LinkingFrom the Moonspell app; null in your own buildsame as iOSnet/launch.ts (table below)
JavaScript engineThe browser'sHermesHermesExpo's defaults (hermesEnabled=true in the Android prebuild)
Canvas resolutionLayout size times PixelRatio.get() (the window's device pixel ratio)Layout size times the screen scaleLayout size times the screen densityrender/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).
  • 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).

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 APIMoonspell appYour Expo build on a phone (no shell)A browser tab, not framedA browser, framed by your page
exitToShell()Back to the app's gamesDoes nothinghistory.back() when there is historyPosts exit to the parent
onShellClosed(listener)Fires when the app closes the gameNever firesNever firesFires on a closed message
isShellActive(), useShellActive()false while the app's feed keeps the game off screenAlways truetrue unless the URL has ?active=0Follows active messages; starts from ?active=0
onShellActive(listener)Fires now, then on every changeFires once with trueFires once with the starting stateFires now, then on every change
getLaunchUrl()The link the game was opened withnullThe ?launch= parameterThe ?launch= parameter
getShareUrl(), roomInviteUrl()The game's public linknullFrom ?share=, else nullFrom ?share=, else null
useLaunchRoomId()Room from the launch linkFalls back to Linking (needs a scheme in app.json)Falls back to Linking, so ?room=CODE worksSame 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​