# Three.js and web engines

> Ship a Vite, Three.js or plain HTML and JavaScript game on Fradiation Games. Covers relative paths, the SDK, resizing, browser permissions and what to watch for in other web engines.

If it runs in a browser, it runs here, provided every URL in it is relative. This page covers games that are already HTML and JavaScript: Vite and Three.js, other bundlers, and plain canvas games.

A build is served from `https://<slug>.containment.cloud/<buildId>/`. A root-absolute URL such as `/assets/game.js` points at the root of that origin, not at your build, and returns a 404. `assets/game.js` and `./assets/game.js` work.

## Vite

Set `base` to `"./"`. Vite's default is `"/"`, which writes root-absolute URLs into `index.html`.

```ts
// vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  // Relative asset URLs. A build is served from /<buildId>/, not from the site root.
  base: "./",
  build: { outDir: "dist", emptyOutDir: true, target: "es2022" },
});
```

Containment Breach, a Three.js game on Fradiation, builds with this `base`. Then:

1. Put `fradiation.json` in `public/`. Vite copies `public/` to the top of `dist/`, which is where the uploader reads it. See [fradiation.json](/docs/fradiation-json).
2. Build.
3. Open `dist/index.html`. Script and link URLs should start with `./`, not `/`:

   ```html
   <script type="module" crossorigin src="./assets/index-DgJRjt5S.js"></script>
   ```

4. Check and zip. [Download check-build.mjs](https://www.fradiation.games/skills/fradiation/check-build.mjs) (Node 20 or later):

   ```bash
   bun run build            # or: npm run build
   node check-build.mjs pack dist game.zip
   ```

Upload `game.zip`, or pick the `dist` folder, on `/dev/<slug>`. See [Publish a game](/docs/publish).

## Loading assets in Three.js

Three.js loaders resolve a relative URL against the page. Put static files in `public/` and refer to them with `./`:

```ts
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

// public/models/ship.glb becomes dist/models/ship.glb
new GLTFLoader().load("./models/ship.glb", (gltf) => scene.add(gltf.scene));
```

`"/models/ship.glb"` breaks. To let Vite fingerprint a file instead, import its URL:

```ts
import shipUrl from "./ship.glb?url";
```

Files that a loader fetches at run time need to be inside the build too:

- **Draco.** Copy `draco_decoder.js`, `draco_decoder.wasm` and `draco_wasm_wrapper.js` from `three/examples/jsm/libs/draco/` into `public/draco/`, and call `dracoLoader.setDecoderPath("./draco/")`. Leave the `README.md` in that folder behind. It isn't an allowed type.
- **KTX2.** `.ktx2` textures are allowed. Copy `basis_transcoder.js` and `basis_transcoder.wasm` from `three/examples/jsm/libs/basis/` into `public/basis/`, and call `ktx2Loader.setTranscoderPath("./basis/")`.
- **Fonts.** `woff` and `woff2` are allowed. Self-host them instead of loading them from another site, especially if you use threads.
- **Formats off the list.** `.hdr`, `.exr`, `.fbx`, `.obj`, `.mtl` and `.drc` aren't allowed. Convert models to `.glb`. Loaders that read raw bytes without checking the extension, such as three's `RGBELoader` and `DRACOLoader`, work with the file renamed to `.bin`.

The full list is in [Builds and uploads](/docs/builds).

## Other bundlers

The rule is the same everywhere: asset URLs are relative.

| Tool | Setting |
| --- | --- |
| Vite | `base: "./"` |
| webpack | `output.publicPath` of `""` |
| Parcel | `--public-url ./` |
| Rollup, esbuild and others | Don't write a leading `/` in asset URLs. |

After a build, open the output `index.html` and search it for `src="/` and `href="/`. Then check every URL your code builds at run time. A local server that serves the output folder at its root hides this mistake. A draft on the site shows it: a draft always runs its newest build.

## Plain HTML and canvas

A game with no build step needs no tooling. Keep everything in one folder with relative references, and zip the folder.

```text
my-game/
  index.html
  game.js
  fradiation.json
  fradiation-sdk.js
  sprites/
    ship.png
  sounds/
    shot.ogg
```

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>My Game</title>
    <style>
      html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
      canvas { display: block; width: 100%; height: 100%; }
    </style>
    <script src="fradiation-sdk.js"></script>
  </head>
  <body>
    <canvas id="game"></canvas>
    <script src="game.js"></script>
  </body>
</html>
```

Zip the `my-game` folder. The uploader drops a single top-level folder, so `index.html` ends up at the top. Or run `node check-build.mjs pack my-game`.

## Load the SDK

The SDK is not on npm. Download a file and ship it inside your build. See [SDK](/docs/sdk) for the API.

**ES module.** Save [fradiation-sdk.mjs](https://www.fradiation.games/sdk/fradiation-sdk.mjs) in your source folder and import it by path:

```ts
import { fradiation } from "./fradiation-sdk.mjs";

const { connected, mode } = await fradiation.ready();

await fradiation.unlockMutation("first-blood");
await fradiation.submitScore("high-score", 12345);
fradiation.track("reached-level", { level: 3 });
```

For TypeScript, save [fradiation-sdk.d.mts](https://www.fradiation.games/sdk/fradiation-sdk.d.mts) next to the module. TypeScript picks it up for the `.mjs` import.

**Script tag.** Save [fradiation-sdk.js](https://www.fradiation.games/sdk/fradiation-sdk.js) next to `index.html`, load it before your game's scripts, and use `window.fradiation`:

```html
<script src="fradiation-sdk.js"></script>
```

Declare mutations and boards in `fradiation.json` first. Outside the site, every call resolves with defaults, so the same code runs in local dev.

## Fill the frame

The game runs in an iframe. The cabinet letterboxes it to the `viewport` aspect ratio in `fradiation.json`, and the frame changes size when the player resizes the window or goes fullscreen. Fill the window and handle resize. Don't hard-code a canvas size.

```html
<style>
  html, body { margin: 0; height: 100%; overflow: hidden; }
  canvas { display: block; position: fixed; inset: 0; width: 100%; height: 100%; }
</style>
```

```ts
function resize() {
  const w = innerWidth;
  const h = innerHeight;
  renderer.setSize(w, h);
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
}
addEventListener("resize", resize);
resize();
```

If you use a post-processing composer, call `composer.setSize(w, h)` in the same function.

## Audio, pointer lock and fullscreen

The frame is sandboxed. It allows pointer lock, popups, modal dialogs and orientation lock, and it delegates `fullscreen`, `gamepad`, `autoplay`, `accelerometer`, `gyroscope` and `xr-spatial-tracking`.

- **Audio.** Browsers can still block sound until the player interacts, even with `autoplay` delegated. Create or resume your `AudioContext` inside the first click or key handler.

  ```ts
  addEventListener("pointerdown", () => audioContext.resume(), { once: true });
  ```

- **Pointer lock.** Request it from a click handler: `canvas.requestPointerLock()`.
- **Fullscreen.** Request it from a click or key handler: `canvas.requestFullscreen()`.
- **Navigation and forms.** The game can't navigate the site or submit forms. Scripts, `fetch` to external APIs and popups work.

## Storage

Each game has its own origin, `<slug>.containment.cloud`. Its `localStorage`, `IndexedDB` and caches belong to that origin and persist across builds. Other games can't read them. Browsers can block storage in an embedded frame, so wrap it and keep the game playable without it:

```ts
try {
  localStorage.setItem("best", String(best));
} catch {
  // storage may be blocked
}
```

## Other engines

Every engine follows the same two rules: relative paths, and file types on the allowed list. This table covers what to watch for. See [Unity](/docs/unity) and [Godot](/docs/godot) for their own guides.

| Engine | Watch for |
| --- | --- |
| Phaser | Relative paths in loader calls: `this.load.image("ship", "assets/ship.png")`, not `"/assets/ship.png"`. Bitmap font `.fnt` files aren't allowed: use the XML format with an `.xml` extension. Tiled maps must be JSON with a `.json` extension. `.tmx` and `.tmj` aren't allowed. |
| Babylon.js | `.glb` and `.gltf` are allowed. `.babylon`, `.env` and `.dds` aren't on the list. |
| Emscripten and custom WebAssembly | `.html`, `.js`, `.wasm` and `.data` are allowed. Build without pthreads: threaded builds don't run on the site yet (see [Threads](/docs/builds#threads)). |
| Anything else | Run `node check-build.mjs <folder>`. It lists every file the rules refuse. A file type that isn't on the allowed list stays refused until it's added. |

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| Blank frame, 404s for scripts or assets | Root-absolute URLs. | Use relative URLs. In Vite, set `base: "./"`. |
| "No index.html at the top of the build" | An extra folder level in the zip, or another entry file name. | Zip the build folder or its contents, or set `entry` in `fradiation.json`. |
| An upload is refused with "file type not allowed" | A file with an extension off the allowed list. | Convert it, or store it under an allowed extension if the loader ignores the extension. Run `check-build.mjs` for the full list. |
| The game doesn't fill the frame | A fixed canvas size. | Size the canvas to 100% and handle `resize`. |
| SDK calls do nothing | The SDK file isn't in the build, the script tag or import path is wrong, the key isn't declared in `fradiation.json`, or the game runs outside the site. | On the site, open DevTools, switch the console to the game's frame and run `await fradiation.ready()`. `connected: true` means the handshake worked. In a draft, a successful call shows a toast. No toast means the site rejected the call. |
| No sound | The browser blocks audio until the player interacts. | Start audio from a click or key handler. |
