Caputchin
Marketplace game development

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-sdk

The 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). null outside a verified session.
  • ctx.locale - resolved language strings (ctx.locale._lang plus your keys), or null.
  • ctx.skin - resolved colors and asset URLs (ctx.skin._theme, always light or dark, plus your keys), or null.
  • ctx.config - resolved gameplay configuration, or null.

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:

  1. Derive every random choice from ctx.seed. Never call Math.random() or read the wall clock for anything that affects the outcome.
  2. 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 inline Blob URLs instead;
  • external CDN fetches at runtime - connect-src blocks 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

See also

On this page