Build a marketplace game
By the end of this tutorial you will have a complete, playable Caputchin game in one JavaScript bundle, ready to make replayable and publish. This is the boring, do-every-step version; read Ship a game to the marketplace first for where it fits.
You need Node and a bundler (esbuild, rollup, vite, or webpack, any of them). You do not need a Caputchin account to build, only to publish.
1. Install the SDK
npm install @caputchin/game-sdkThe SDK is tiny: a register helper plus TypeScript types. It does not bundle the widget runtime, so your game stays small.
2. Register a game factory
A game is a single call to register with a factory function. The widget invokes your factory inside the sandboxed iframe; that call is itself the start signal (there is no separate start event to wait for).
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;
});You do not pass the manifest to register; the server reads caputchin.json at index time and ships the resolved presets down to your factory as ctx. See the SDK reference for the full surface.
3. Render from the per-round context
The factory's third argument, ctx, carries everything that changes per visitor:
ctx.seed- the per-round seed. Derive all randomness from this (see step 5).nulloutside a verified session.ctx.locale- resolved language strings (ctx.locale._langplus your keys), ornull.ctx.skin- resolved colors and asset URLs (ctx.skin._theme, alwayslightordark, plus your keys), ornull.ctx.config- resolved gameplay configuration, ornull.
Read your own keys off these; you never resolve presets yourself. Each is null when your manifest declares no matching block, so always fall back to a built-in default:
function startGame(container, bridge, ctx) {
const title = ctx.locale?.title ?? "Tap the targets";
const accent = ctx.skin?.accent_color ?? "#2da44e";
const targetCount = ctx.config?.target_count ?? 5;
// ...render with these...
}If your layout needs an explicit size, call bridge.setSize(width, height) once after your first paint.
4. Win, lose, and errors
Your game decides when the player wins and tells the host through the bridge:
- On a win, call
bridge.pass({ trace })with the round's trace (step 5). - On a loss or abandonment, call nothing; silence is the failure signal.
- If the game itself breaks (an asset failed, an exception), call
bridge.error({ code, message }). That is for game-internal failure, not a player losing.
function onWin(traceString) {
bridge.pass({ trace: traceString });
}
function onAssetFailure(err) {
bridge.error({ code: "asset-load-failed", message: String(err) });
}5. Record a trace
This is the step that makes the game replayable. As the player acts, record the inputs that drive the outcome (which target they hit, in order, with timing if it matters) and serialize that record to a string or byte array: that is your trace. Combined with the seed, the trace must let your logic reproduce the exact result, because the server re-runs it.
Two rules make that possible:
- Derive every random choice from
ctx.seed. Never callMath.random()or read the wall clock for anything that affects the outcome. - Record enough to replay. The trace plus the seed is the complete input to your game logic.
function makeRng(seed) {
// seed is ctx.seed; feed it so the server reproduces the same sequence.
let s = hashSeed(seed);
return () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
}The shape of seed, the trace, and the verdict are defined by the replay contract; that page covers turning this recorded play into the headless run artifact the server replays. If hand-writing deterministic logic sounds fiddly, the engine kit does it for you, optionally.
6. Bundle for the sandbox
The widget loads exactly one script URL, so everything must be in that one file. Configure your bundler for a single 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 opaque-origin, strict-CSP iframe:
- 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.
7. Test it locally
There is no special harness. Embed the widget in a static HTML page, point game-src at your local bundle output, and play it in a browser:
<caputchin-game sitekey="cpt_pub_..." game-src="http://localhost:8080/game.js"></caputchin-game>This is the same game-src path a custom game uses; for the marketplace you will instead publish the repo so the platform pins the bundle for you.
Next steps
- The replay contract: turn your recorded play into the
runartifact that makes the game replayable. - The caputchin.json manifest: describe the game, its presets, and its bundle.
- Publish to the marketplace: tag the repo and go live.
See also
- Game SDK reference: every export, in full.
- The engine kit: the optional way to get determinism for free.