---
type: tutorial
---

# Author a Caputchin game

Build a game that runs inside the [widget](../widget.md). Reference for the contract is [game-sdk](../game-sdk.md).

## 1. Install the SDK

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

## 2. Register your game

```javascript
import { register } from '@caputchin/game-sdk';

register('@your-org/clicker', (container, bridge) => {
  container.innerHTML = `
    <button id="hit">Click me 5 times</button>
    <span id="count">0</span>
  `;

  let count = 0;
  const startedAt = performance.now();

  container.querySelector('#hit').addEventListener('click', () => {
    count += 1;
    container.querySelector('#count').textContent = String(count);
    if (count >= 5) {
      bridge.complete({ score: 1.0, durationMs: performance.now() - startedAt });
    }
  });

  return () => {
    // optional cleanup if the widget unmounts your game
  };
});
```

The function receives a `container` element inside the game's sandboxed iframe and a `bridge` for pushing events upward. Return an optional cleanup function. The realm is opaque-origin — your code cannot reach the customer's page DOM, cookies, or storage. See [widget isolation](../widget.md#isolation) and [ADR-0015](../adr/0015-sandbox-game-iframe.md).

`bridge` is push-only. Two methods:

- `bridge.complete({ score, durationMs })` — call when a round finishes. The widget releases Cap redeem on the first call.
- `bridge.error({ code, message })` — call if your game cannot continue. The widget surfaces an `error` event to the customer.

You do not subscribe to widget events. The widget controls game start via [`mode`](../widget.md#modes); the factory being invoked is itself the start signal.

## 3. Score normalization

`score` is `0.0–1.0`. There is no `maxScore`. Normalize at the source. Score is gameplay metadata, not a security signal — see [ADR-0002](../adr/0002-no-risk-scoring.md).

## 4. Multi-round and replay

`bridge.complete(...)` may be called more than once if your game supports replay or multi-round. The widget handles dedup of verification automatically — see [api](../api.md#post-gamecomplete-browser--platform). You don't need to think about it; just call `bridge.complete(...)` whenever a round finishes.

## 5. Bundle for the sandbox

The widget loads exactly one script URL per game; everything has to be inside that single file. Configure your bundler (esbuild, rollup, vite, webpack — any of them) for a single self-contained output with:

- assets inlined as data URLs (sprites, sounds, fonts)
- code splitting disabled
- inline source maps if you want devtools-friendly debugging
- WASM, if used, embedded as base64 and instantiated from bytes

Patterns that don't work inside the sandbox:

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

These constraints apply identically to marketplace and customer-hosted games. See [game-distribution](../game-distribution.md#bundle-constraint).

## 6. Test locally

There's no special local-test harness yet. Embed the widget in a static HTML page with your site key from the [dashboard](../dashboard.md), point `game-src` at your local bundle output, and exercise the flow in a browser.

## 7. Distribute

Two equivalent paths — see [game-distribution](../game-distribution.md):

- **Marketplace**: tag your repo `caputchin-game` and add a [`caputchin.json` manifest](publish-to-marketplace.md). The indexer derives a jsDelivr URL and computes the SRI hash; customers reach your game by ID.
- **Customer-hosted**: ship your built JS file; the customer drops it in their static-asset directory and passes the URL via the widget's `game-src` attribute.

Same code, both paths.

## What is intentionally absent from the contract

- No `sessionId` parameter
- No `unmount` method (return a cleanup function)
- No lifecycle hooks (`mount`, `pause`, `resume`)
- No `maxScore`

Tiny by design — see [game-sdk](../game-sdk.md#what-is-intentionally-absent).
