Skip to main content

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​

ScriptRunsWhat it is for
npm run setup:websetup-skia-web publicCopies canvaskit.wasm into public/ on its own
npm run websetup-skia-web public && expo start --webThe dev server in the browser
npm run export:websetup-skia-web public && expo export --platform webA static site in dist/
npm run typechecktsc --noEmitType-check the game and engine
npm testthe engine's unit testsRun 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:

  1. If navigator has no gpu, or navigator.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.
  2. 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 sets globalThis.__crescentBase. A plain Expo export does not set it, so CanvasKit is fetched from canvaskit.wasm next to index.html.
  3. It installs the engine's font fallback for Skia (src/engine/web/systemFont.ts): CanvasKit has no system fonts, so every matchFont() answers with the engine's bundled Nunito faces.
  4. 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.
  5. 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​

PiecePhonesBrowser
Views, text, PressableReact Nativereact-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 buildCanvasKit (canvaskit.wasm)
Gesturesreact-native-gesture-handlerits web build
Soundreact-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 dataMMKVMMKV'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.

BrowserStatus
Google Chrome 157, macOS, Apple GPUTested: dev server and static export, 3D and 2D, touch buttons and keyboard
Edge, Safari, FirefoxNot tested
Windows, Linux, Android Chrome, iOS SafariNot 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):

PathWhat
index.htmlExpo's default page. Its title is expo.name from app.json
_expo/static/js/web/index-<hash>.jsThe 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.wasmCopied from public/ (about 8 MB)
metadata.jsonExpo'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 .wasm files as application/wasm.
  • The export uses absolute URLs (/_expo/..., /assets/...), so put dist/ at the root of a domain or subdomain.
  • To host under a sub-path, Expo has the experimental expo.experiments.baseUrl in app.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 and canvaskit.wasm is not found. This follows from webBase() and was not tested either.
  • The page fills the browser window (Expo's #root is 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.ico in the export, so browsers log a 404 for it. Harmless; add one to public/ 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.

ControlKeys
TouchControlsA or Left steer left, D or Right steer right, W or Up gas, S, Down or Space brake
JoystickWASD or the arrows, eight directions at full tilt
CharacterControlsWASD 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.code values (the physical key), so WASD stays in place on any keyboard layout.
  • Keys are ignored while an input, textarea, select or 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:

typeExtra fieldsWhen
readynoneThe game is registered and starting
errormessage (string)The web boot failed; the frame shows "This game could not start"
unsupportedreason: 'webgpu'No navigator.gpu, or no adapter; the frame shows the browser notice
exitnoneThe game called exitToShell() (the template's exit)
pinchscale (1 down to 0)Two fingers, or a trackpad pinch, are closing in on the game
pinch-endexit (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':

typeExtra fieldsWhat the game does
closednoneonShellClosed listeners run; the template's App.tsx unmounts the game (sound, simulation and rendering stop) and shows its "Play" button
activeactive (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​

ParameterRead as
?active=0The 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 pinch and pinch-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() calls history.back() when there is history, and the template's App.tsx shows the "Play" button.

Known issues​

  • THREE.Clock: This module has been deprecated is 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_modules breaks the web bundle. An install that is missing react-native-web fails with Unable to resolve "react-native-web/dist/exports/Pressable". A node_modules reached through a symlink fails too, because Metro resolves a package's imports from its real path. Run a real npm install in the game's folder.
  • public/ goes into every Expo export. After npm run web, public/canvaskit.wasm stays on disk and a later expo export --platform ios or android copies it into that output as well. Harmless for a game of your own; delete public/canvaskit.wasm before a phone export if the 8 MB matters.

See also​