---
type: tutorial
title: Build a self-hosted game
summary: Build a sandboxed game bundle with the game SDK, host it yourself, point the widget at it with game-src, and produce the deterministic trace the server replays.
---

By the end of this tutorial you will have a real Caputchin game: a JavaScript bundle that runs in the widget's sandboxed iframe, talks to the host through the game SDK, and reports a **trace** the server can re-run. Unlike [manual mode](/docs/custom-game-development/manual-mode), a self-hosted game can [gate a site key](/docs/site-keys/games), because its round is replayable. This tutorial covers building and hosting the game; [replay and gating](/docs/custom-game-development/replay-and-gating) covers the artifact that actually turns the gate on.

Read [run your own game](/docs/custom-game-development/overview) for where this fits. You need a place to host a static JavaScript file (any CDN or static host) and the widget on your page.

## The shape of a self-hosted game

A self-hosted game is one self-contained JavaScript bundle that:

1. Imports the `@caputchin/game-sdk` `register` function and registers a single **game factory**.
2. Renders into the container the widget gives the factory, using the resolved language, skin, and configuration passed alongside it.
3. Plays out deterministically under the per-round seed, recording the play as a **trace**.
4. On a win, calls `bridge.pass({ trace })` with that record.

The widget loads your bundle into its iframe and invokes your factory; the factory being called *is* the start signal (there is no separate start event to wait for).

## 1. Register a game factory

Install the SDK and call `register` once with your factory. You do **not** pass the manifest; the server resolves presets and hands them to the factory.

```js
import { register } from "@caputchin/game-sdk";

register((container, bridge, ctx) => {
  // container — a DOM element inside the sandboxed iframe; render into it.
  // bridge    — push-only channel to the host (pass / error / setSize).
  // ctx       — the per-round context (seed + resolved locale/skin/config).

  const cleanup = startGame(container, bridge, ctx);

  // Return an optional cleanup function; the widget calls it on unmount.
  return cleanup;
});
```

```bash
npm install @caputchin/game-sdk
```

## 2. Read the per-round context

The factory's third argument, `ctx`, carries everything your game needs for this visitor:

- `ctx.seed` — the per-round replay seed. **Derive all randomness from this.** Null when the game runs outside a verified session.
- `ctx.locale` — the resolved language object: `ctx.locale._lang` plus your translated string keys.
- `ctx.skin` — the resolved skin object: `ctx.skin._theme` (always `light` or `dark`) plus your color and asset keys.
- `ctx.config` — the resolved gameplay configuration (or null).

These are the **resolved presets** for this visitor, produced by Caputchin from your dashboard-defined [schema and presets](/docs/custom-game-development/dashboard-schema). You read your own keys off them; you do not resolve anything yourself.

If your layout needs an explicit size, tell the host once after your first paint:

```js
bridge.setSize(360, 480);
```

## 3. Make the play deterministic

This is the rule that makes a game gateable: **the game must be deterministic given the seed and the player's inputs.** Do not call `Math.random()` or read the wall clock for anything that affects the outcome; derive every random choice from `ctx.seed`. The server re-runs your logic under the same seed and the same recorded trace and must reach the same verdict.

```js
// A tiny seeded PRNG; feed it ctx.seed so the server reproduces the run.
function makeRng(seed) {
  let s = hashToInt(seed);
  return () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
}
```

Record the inputs that drive the outcome (which targets the player hit, in order, with timing if it matters) as you play. Serialize that record to a string: that string is your trace.

## 4. Pass with the trace

When the visitor wins, hand the host the trace. `pass` takes an object with a single `trace` string:

```js
function onWin(traceString) {
  bridge.pass({ trace: traceString });
}
```

The trace is **opaque to the platform**: it is whatever string your game alone defines, such that your logic, combined with the seed, reproduces the result. The game does not report a score here; scoring, if any, is your own in-iframe concern.

Two more bridge facts:

- The first `pass` redeems the round; a failed or abandoned round is signalled by simply **not calling** `pass` (there is no `fail` method on this bridge).
- `bridge.error({ code, message })` is for a game-internal failure (an asset failed to load, an exception), not a player loss. It surfaces an `error` event to the host.

After the host redeems the pass, it releases the widget token, and your backend [verifies that token](/docs/integration-guides/verify-on-your-backend) as usual.

## 5. Bundle for the sandbox

The widget loads exactly one script URL, so everything must be in that single file. Configure your bundler (esbuild, rollup, vite, webpack; any of them) for one self-contained output:

- inline assets as data URLs (sprites, sounds, fonts);
- disable code splitting;
- if you use WASM, embed it as base64 and instantiate from bytes.

Patterns that do **not** work inside the sandbox, because the iframe is opaque-origin with a strict CSP:

- path-relative `fetch('./sprite.png')` — there is no path to fetch from;
- dynamic `import('./chunk.js')` — the second URL is blocked;
- `new Worker('./worker.js')` — spawn workers from inline `Blob` URLs instead;
- external CDN fetches at runtime — `connect-src` blocks them.

## 6. Host the bundle and point the widget at it

Serve the built file from your own static host over `https` (loopback `http` is allowed for local dev), then point the widget at it:

```html
<caputchin-game
  sitekey="cpt_pub_..."
  game-src="https://cdn.example.com/my-game/game.js"
></caputchin-game>
```

The widget loads your bundle into its sandboxed iframe, invokes your factory with the context, and waits for your `pass`. At this point the game runs and verifies, but it is not yet allowed to **gate** a key.

## 7. Make it gateable

To use your self-hosted game as a verification gate, two more things are needed, both covered next:

1. **Register it as a custom game** on the dashboard (an id you choose) and define its field [schema and presets](/docs/custom-game-development/dashboard-schema) so the context carries real locale/skin/config.
2. **Upload a replay artifact**: a headless build of your game logic the server runs to re-derive the verdict from the seed and trace. See [replay and gating](/docs/custom-game-development/replay-and-gating).

Until the replay artifact is uploaded and passes its self-check, the custom game shows **Not replayable** and cannot gate.

## See also

- [Replay and gating](/docs/custom-game-development/replay-and-gating): the artifact that turns the gate on.
- [Dashboard schema reference](/docs/custom-game-development/dashboard-schema): defining the fields the context resolves.
- [Manual mode](/docs/custom-game-development/manual-mode): the lighter, non-gating alternative.
- [Verify on your backend](/docs/integration-guides/verify-on-your-backend): confirming the token a pass produces.
