Build a self-hosted game
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, a self-hosted game can gate a site key, because its round is replayable. This tutorial covers building and hosting the game; replay and gating covers the artifact that actually turns the gate on.
Read run your own game 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:
- Imports the
@caputchin/game-sdkregisterfunction and registers a single game factory. - Renders into the container the widget gives the factory, using the resolved language, skin, and configuration passed alongside it.
- Plays out deterministically under the per-round seed, recording the play as a trace.
- 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.
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;
});npm install @caputchin/game-sdk2. 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._langplus your translated string keys.ctx.skin— the resolved skin object:ctx.skin._theme(alwayslightordark) 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. 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:
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.
// 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:
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
passredeems the round; a failed or abandoned round is signalled by simply not callingpass(there is nofailmethod 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 anerrorevent to the host.
After the host redeems the pass, it releases the widget token, and your backend verifies that token 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 inlineBlobURLs instead;- external CDN fetches at runtime —
connect-srcblocks 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:
<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:
- Register it as a custom game on the dashboard (an id you choose) and define its field schema and presets so the context carries real locale/skin/config.
- 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.
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: the artifact that turns the gate on.
- Dashboard schema reference: defining the fields the context resolves.
- Manual mode: the lighter, non-gating alternative.
- Verify on your backend: confirming the token a pass produces.