caputchin
All docs
View raw .md

Author a Caputchin game

Build a game that runs inside the widget. Reference for the contract is game-sdk.

1. Install the SDK

npm install @caputchin/game-sdk

2. Register your game

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 and ADR-0015.

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; 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.

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

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, point game-src at your local bundle output, and exercise the flow in a browser.

7. Distribute

Two equivalent paths — see game-distribution:

  • Marketplace: tag your repo caputchin-game and add a caputchin.json manifest. 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.