Skip to content

Docs / Engines

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.

.md

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.

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

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.

Other bundlers

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

ToolSetting
Vitebase: "./"
webpackoutput.publicPath of ""
Parcel--public-url ./
Rollup, esbuild and othersDon'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 for the API.

ES module. Save 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 next to the module. TypeScript picks it up for the .mjs import.

Script tag. Save 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 and Godot for their own guides.

EngineWatch for
PhaserRelative 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).
Anything elseRun 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

SymptomCauseFix
Blank frame, 404s for scripts or assetsRoot-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 frameA fixed canvas size.Size the canvas to 100% and handle resize.
SDK calls do nothingThe 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 soundThe browser blocks audio until the player interacts.Start audio from a click or key handler.