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 anerrorevent 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 inlineBlobURLs 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-gameand add acaputchin.jsonmanifest. 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-srcattribute.
Same code, both paths.
What is intentionally absent from the contract
- No
sessionIdparameter - No
unmountmethod (return a cleanup function) - No lifecycle hooks (
mount,pause,resume) - No
maxScore
Tiny by design — see game-sdk.