# Web

> How a Moonspell engine game runs in a browser, from the web entry and Skia's CanvasKit to npm run web, a static export in dist/, hosting it, keyboard input, embedding it in a page with an iframe, and the known issues.

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](https://docs.moonspell.app/start/open-source.md)).

## Run it

From the game's folder (the template is `templates/crescent-game/`):

```bash
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`:

```bash
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:

```bash
npm run export:web
```

It writes `dist/`. To try it locally, serve the folder with any static server, for example:

```bash
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`:

```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](https://docs.moonspell.app/engine/platform-layer.md) 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](https://docs.moonspell.app/platforms/differences.md). How the 3D canvas and the renderer are set up is in [Canvas and rendering](https://docs.moonspell.app/engine/canvas-and-rendering.md).

## 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 `.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](https://docs.expo.dev/more/expo-cli/#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.

| 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.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](https://docs.moonspell.app/engine/input.md).

## 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](https://docs.moonspell.app/engine/net.md)).

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

```html
<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](https://docs.moonspell.app/engine/audio.md)).
- **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

- [Platform differences](https://docs.moonspell.app/platforms/differences.md): every difference between web, iOS and Android in one table.
- [Platform layer](https://docs.moonspell.app/engine/platform-layer.md): the files that make one source run on three platforms.
- [iOS](https://docs.moonspell.app/platforms/ios.md) and [Android](https://docs.moonspell.app/platforms/android.md): development builds of the same game.
- [Quickstart](https://docs.moonspell.app/start/quickstart.md): from clone to the default game in a browser.
