Web
The web build is the same game source as the phone builds, bundled by Expo for the browser. React Native's views run on react-native-web, three.js draws through the browser's own WebGPU, and Skia draws through CanvasKit, its WebAssembly build. Use it to develop fast (reload on save, browser dev tools) and to put a game on any website.
Everything on this page was run in this repository with plain Expo, on macOS in Google Chrome 157, unless a line says otherwise. The Moonspell app loads games in its own way, not with these commands (What is and is not open).
Run it
From the game's folder (the template is templates/crescent-game/):
npm install
npm run web
Open the URL Expo prints (http://localhost:8081 by default) in a browser with WebGPU. Arguments after -- go to expo start --web:
npm run web -- --port 8790
The dev server is a development bundle, so __DEV__ is true: game code that only shows in development (the racing module's AUTO button and telemetry line, for example) is visible here and absent from an export.
To make a static site:
npm run export:web
It writes dist/. To try it locally, serve the folder with any static server, for example:
cd dist && python3 -m http.server 8765 --bind 127.0.0.1
and open http://127.0.0.1:8765/.
The scripts
| Script | Runs | What it is for |
|---|---|---|
npm run setup:web | setup-skia-web public | Copies canvaskit.wasm into public/ on its own |
npm run web | setup-skia-web public && expo start --web | The dev server in the browser |
npm run export:web | setup-skia-web public && expo export --platform web | A static site in dist/ |
npm run typecheck | tsc --noEmit | Type-check the game and engine |
npm test | the engine's unit tests | Run before an export |
setup-skia-web is a bin shipped by @shopify/react-native-skia. It copies node_modules/canvaskit-wasm/bin/full/canvaskit.wasm (about 8 MB) to public/canvaskit.wasm. Expo serves public/ at the site root in the dev server and copies it into dist/ on export. The file is in .gitignore. Skia's docs say to run the script again after every Skia upgrade; the two scripts above do it on every run. It prints "Metro bundler not detected. Assuming the project is using Webpack.": harmless, the scripts pass the folder explicitly.
How the browser build starts
The template's package.json has "main": "./index", so Expo picks index.web.ts for the browser and index.ts for the phones. No custom index.html, Metro or Babel change is needed for the web.
index.web.ts:
import { bootWeb } from './src/engine/web/boot';
bootWeb(() => require('./src/App').default);
bootWeb (src/engine/web/boot.ts) does this, in order:
- If
navigatorhas nogpu, ornavigator.gpu.requestAdapter()returns no adapter, it replaces the page with "This browser can't run this game yet. Try the latest Chrome, Edge or Safari." and stops. There is no WebGL path. - It loads CanvasKit with
LoadSkiaWeb({ locateFile: (file) => webBase() + file }).webBase()is the folder of the page's own URL (new URL('.', document.baseURI)), unless the page setsglobalThis.__crescentBase. A plain Expo export does not set it, so CanvasKit is fetched fromcanvaskit.wasmnext toindex.html. - It installs the engine's font fallback for Skia (
src/engine/web/systemFont.ts): CanvasKit has no system fonts, so everymatchFont()answers with the engine's bundled Nunito faces. - It requires the game and registers it. The game is required inside the callback, not imported at the top: Skia's web build reads CanvasKit the moment it is evaluated.
- If a step throws, it shows "This game could not start. Reload to try again." and logs the error to the console.
Platform layer covers the per-platform files in more detail.
The libraries on the web
| Piece | Phones | Browser |
|---|---|---|
Views, text, Pressable | React Native | react-native-web 0.21.4 (DOM elements) |
3D (EngineCanvas) | three.js WebGPURenderer on react-native-webgpu (Dawn) | three.js WebGPURenderer on the browser's navigator.gpu. react-native-webgpu's Canvas is a wrapper around an HTML canvas there |
| Skia (HUD, 2D layer) | Skia's native build | CanvasKit (canvaskit.wasm) |
| Gestures | react-native-gesture-handler | its web build |
| Sound | react-native-audio-api (the mixer), expo-audio (RaceAudio, the 2D layer's one-shots) | the same packages' web builds: react-native-audio-api wraps the browser's AudioContext, expo-audio plays HTML audio elements |
| On-device data | MMKV | MMKV's web build, on localStorage |
The full list of differences is in Platform differences. How the 3D canvas and the renderer are set up is in Canvas and rendering.
Browsers
The engine needs WebGPU. Its own notice names current Chrome, Edge and Safari.
| Browser | Status |
|---|---|
| Google Chrome 157, macOS, Apple GPU | Tested: dev server and static export, 3D and 2D, touch buttons and keyboard |
| Edge, Safari, Firefox | Not tested |
| Windows, Linux, Android Chrome, iOS Safari | Not tested |
A browser without navigator.gpu, or with one that returns no adapter, shows the notice and nothing else.
Headless Chrome needs flags to expose WebGPU. What worked on macOS: --headless=new --enable-unsafe-webgpu --enable-features=WebGPU --use-angle=metal. A normal desktop Chrome needs no flags.
What is in dist/
From one export of the template's default game (16 MB in total):
| Path | What |
|---|---|
index.html | Expo's default page. Its title is expo.name from app.json |
_expo/static/js/web/index-<hash>.js | The whole game and engine as one bundle (5.4 MB in this test) |
assets/... | The game's audio and models, the Nunito fonts the web boot loads, the Material Symbols font, react-native-audio-api icons |
canvaskit.wasm | Copied from public/ (about 8 MB) |
metadata.json | Expo's export metadata |
There is one JavaScript file because games never use dynamic import(): the engine's rule, so a bundle can be served from any folder.
Hosting a static export
dist/ is a plain static site. Any static host works: object storage behind a CDN, a static site service, your own server.
- Serve
.wasmfiles asapplication/wasm. - The export uses absolute URLs (
/_expo/...,/assets/...), so putdist/at the root of a domain or subdomain. - To host under a sub-path, Expo has the experimental
expo.experiments.baseUrlinapp.json, set before exporting (Expo: hosting with sub-paths). Not tested with the engine. CanvasKit is looked up relative to the page's URL, so serve the page with a trailing slash (/games/sunset-sprint/, not/games/sunset-sprint): without it, the page's folder is the parent folder andcanvaskit.wasmis not found. This follows fromwebBase()and was not tested either. - The page fills the browser window (Expo's
#rootis full height). The engine does not frame the game at phone size; if you want a phone-shaped game on a wide screen, put it in an iframe of that size (below). - There is no
favicon.icoin the export, so browsers log a 404 for it. Harmless; add one topublic/if you want one.
Keyboard input
A mouse is one finger, so the engine's controls also read the keyboard in a browser (src/engine/input/keyboard.ts). TouchControls, Joystick and CharacterControls each listen and feed the same input the thumbs do. A game built on them plays with keys with no change.
| Control | Keys |
|---|---|
TouchControls | A or Left steer left, D or Right steer right, W or Up gas, S, Down or Space brake |
Joystick | WASD or the arrows, eight directions at full tilt |
CharacterControls | WASD or the arrows move; action buttons by position: Space, then J, E or F, then K or Q, then L; a button with id sprint is Shift |
- Keys are
KeyboardEvent.codevalues (the physical key), so WASD stays in place on any keyboard layout. - Keys are ignored while an
input,textarea,selector editable element has focus, and while Cmd, Ctrl or Alt is held. - While a control listens, the browser's default for the game keys (scrolling on arrows and Space) is cancelled.
- Leaving the tab (
blur) releases every held key.
The full input reference is on Input.
Embedding a game in a page
Put the exported game in an <iframe> and the engine talks to the page around it with postMessage (src/engine/shellModule.web.ts). This is the same channel the Moonspell web app uses; the engine side is described here. When the game is not in a frame, none of this runs.
The game counts as framed when window.parent !== window.
Messages the game sends
Posted to window.parent with target origin *. Each is a plain object with crescent: 'game' and a type:
type | Extra fields | When |
|---|---|---|
ready | none | The game is registered and starting |
error | message (string) | The web boot failed; the frame shows "This game could not start" |
unsupported | reason: 'webgpu' | No navigator.gpu, or no adapter; the frame shows the browser notice |
exit | none | The game called exitToShell() (the template's exit) |
pinch | scale (1 down to 0) | Two fingers, or a trackpad pinch, are closing in on the game |
pinch-end | exit (boolean) | The pinch ended; true when it ended past the close threshold |
Messages the game reads
Only messages whose event.source is window.parent and whose data has crescent: 'shell':
type | Extra fields | What the game does |
|---|---|---|
closed | none | onShellClosed listeners run; the template's App.tsx unmounts the game (sound, simulation and rendering stop) and shows its "Play" button |
active | active (boolean) | isShellActive() changes and onShellActive listeners run; the engine pauses its frame loop, the 2D loop and the audio while it is false |
The query string
| Parameter | Read as |
|---|---|
?active=0 | The game starts paused (isShellActive() is false) until an active message says true |
?launch=<url> | getLaunchUrl(): the link the game was opened with. useLaunchRoomId() reads a room code from it |
?share=<url> | getShareUrl(): the game's public link, used by roomInviteUrl() |
These parameters work without a frame too. useLaunchRoomId() also reads ?room=CODE from the page's own URL through React Native's Linking (Net).
A framing page
A sketch of a page that frames a game, written against the message shapes above. It was not run by the authors.
<iframe
id="game"
src="https://games.example.com/?active=0"
style="width: 430px; height: 880px; border: 0"
></iframe>
<script>
const frame = document.getElementById('game');
const GAME_ORIGIN = 'https://games.example.com';
window.addEventListener('message', (event) => {
if (event.source !== frame.contentWindow) return;
const data = event.data;
if (!data || data.crescent !== 'game') return;
if (data.type === 'ready') {
frame.contentWindow.postMessage({ crescent: 'shell', type: 'active', active: true }, GAME_ORIGIN);
frame.focus();
}
if (data.type === 'unsupported') console.log('This browser has no WebGPU');
if (data.type === 'exit') frame.remove();
});
// To stop the game without removing the frame:
// frame.contentWindow.postMessage({ crescent: 'shell', type: 'closed' }, GAME_ORIGIN);
</script>
Things to know when framing:
- Keep the frame at a fixed size while the game plays (see Known issues).
- Keyboard events go to the focused document: the player has to click into the frame, or the page calls
frame.focus(), before the keys reach the game. - In a frame, the game installs a pinch watcher on its own window: two fingers that both move toward each other, a Ctrl plus wheel event (how Chrome, Edge and Firefox report a trackpad pinch), or Safari's gesture events. It cancels the browser's page zoom for those inside the frame and reports
pinchandpinch-end. Your page may ignore them; nothing else happens. - The game does not check the parent's origin; it only checks that a message came from its parent window. It also posts with
*, so do not rely on these messages for anything secret. - Outside a frame,
exitToShell()callshistory.back()when there is history, and the template'sApp.tsxshows the "Play" button.
Known issues
THREE.Clock: This module has been deprecatedis logged twice on load. Harmless.- Sound starts on the first tap. Browsers hold audio back until the page gets a user gesture; the engine starts the mixer and the race loops on the first tap, click or key (Audio).
- A stale or symlinked
node_modulesbreaks the web bundle. An install that is missingreact-native-webfails withUnable to resolve "react-native-web/dist/exports/Pressable". Anode_modulesreached through a symlink fails too, because Metro resolves a package's imports from its real path. Run a realnpm installin the game's folder. public/goes into every Expo export. Afternpm run web,public/canvaskit.wasmstays on disk and a laterexpo export --platform iosorandroidcopies it into that output as well. Harmless for a game of your own; deletepublic/canvaskit.wasmbefore a phone export if the 8 MB matters.
See also
- Platform differences: every difference between web, iOS and Android in one table.
- Platform layer: the files that make one source run on three platforms.
- iOS and Android: development builds of the same game.
- Quickstart: from clone to the default game in a browser.