Widget
The customer-facing browser surface — a single web component plus two events. Distributed two ways, identical behavior:
- CDN:
https://cdn.jsdelivr.net/npm/@caputchin/widget@1/dist/widget.js(script tag, auto-registers). See ADR-0009 for why we use jsDelivr instead of a custom CDN. - npm:
@caputchin/widget(browser-runtime only). The game-authorregister()helper is a separate package,@caputchin/game-sdk— see ADR-0008.
Element shape
<caputchin-widget sitekey="cpt_pub_..." game="@cooperative-games/bubble-pop"></caputchin-widget>
| Attribute | Required | Behavior |
|---|---|---|
sitekey |
yes | Public site key from the dashboard |
game |
optional | Marketplace game ID, or a customer-chosen ID used together with game-src |
games |
optional | Comma-separated list of marketplace IDs; widget picks one at random client-side |
game-src |
optional | Absolute or page-relative URL to a customer-hosted game script. Required when the game ID is not a marketplace ID. See game-distribution. |
mode |
optional | Trigger and control mode. See Modes. |
If neither game / games nor game-src is set, the widget runs the invisible default — Cap-only verification, no game UI, score: null in the wrapped token.
On successful completion the widget injects a hidden <input name="caputchin-token"> into the enclosing form. Customer's backend posts that field to /siteverify with the secret.
Events
const widget = document.querySelector('caputchin-widget');
widget.addEventListener('start', (e) => { /* e.detail.gameId */ });
widget.addEventListener('complete', (e) => { /* e.detail.token, score, durationMs */ });
widget.addEventListener('nickname', (e) => { /* e.detail.nickname */ });
widget.addEventListener('error', (e) => { /* e.detail.code, message, originalCode */ });
| Event | Fires when | event.detail |
|---|---|---|
start |
Game has begun. In iframe modes (auto+game, form-submit+game), fires when the iframe sends game-started. In the invisible default (no game) and manual mode, fires when Cap solve() begins — no iframe is involved so there is no game-started to wait for. |
{ gameId } — the resolved game ID, or null in the invisible default |
complete |
Wrapped token is built (first completion only) | { token, score, durationMs } — token is also injected as caputchin-token into the enclosing form. score / durationMs are nullable. |
nickname |
Scoreboard handle was set, either by the widget's own prompt or by widget.setNickname() (Post-MVP) |
{ nickname } — the 3-letter string |
error |
Anything blocks the flow | { code, message, originalCode? } — code is a value from the widget's documented ErrorCode set. originalCode is set only when a game calls bridge.error({ code }) with a code outside that set; code is then game-error-relayed and originalCode carries the author-supplied value verbatim. |
The start event ordering depends on whether a game is configured. In iframe modes (auto+game, form-submit+game), Cap's solve() begins in parallel with the game loading inside the sandboxed iframe. The start event fires only after the iframe sends game-started — confirming the game actually loaded and ran — so customers can rely on start as a signal that gameplay has begun, not merely that Cap has started churning. In the invisible default (no game) and in manual mode there is no iframe, so start fires immediately when Cap begins.
Every programmatic method fires the corresponding event so customers can observe progress in any mode without owning the call site. By design — see principles. Anything fancier (per-round score deltas, stats, replay timelines) belongs in the dashboard, not in widget callbacks.
Modes
The mode attribute controls when Cap starts and who drives the lifecycle. Game presence is orthogonal to the trigger: any non-manual mode supports either an iframe-hosted game (when game / game-src is set) or the invisible default (when neither is set).
mode value |
When Cap fires | Driver | Compatible with game / game-src |
|---|---|---|---|
(unset) / auto |
On widget mount | Widget | Yes — game loads at the same moment |
form-submit |
When the enclosing <form> is submitted; submission is held until the wrapped token is ready |
Widget intercepts form submit | Yes |
manual |
When the customer calls widget.start() |
Customer fully drives lifecycle from the host page realm; widget loads no game | No — setting game or game-src together with mode="manual" emits error with code invalid-config |
manual is the deliberate opt-out from sandbox isolation. The customer's own host-realm code runs whatever game / UX they like and calls the widget back to drive Cap. There is no third-party code involved by definition, so the iframe contributes nothing — the customer trusts their own bundle the same way they trust the rest of their app. Marketplace and customer-hosted modes remain sandboxed; manual is explicit and verbose in markup so the trust trade is visible at the integration point. See principles — untrusted by default and ADR-0015.
Programmatic API (manual mode)
const widget = document.querySelector('caputchin-widget');
widget.start(); // begin Cap solve; held until complete()
widget.complete({ score, durationMs }); // release redeem; emit complete event
widget.setNickname('AAA'); // Post-MVP scoreboards only; optional
| Method | Effect |
|---|---|
widget.start() |
Calls Cap solve() in the widget realm, fires /game/start. Idempotent — repeat calls are no-ops. |
widget.complete({ score, durationMs }) |
Releases the gated Cap redeem via CAP_CUSTOM_FETCH, fires /game/complete, builds the wrapped token, emits the complete event. Repeat calls record additional rounds; the wrapped token is locked at the first call. Either field is optional and metadata-only — see game data is metadata. |
widget.setNickname(letters) (Post-MVP, scoreboards) |
Not yet implemented — throws Error('not implemented') in the current build. Will call /game/nickname when the platform endpoint ships. |
Only available in manual mode. In auto and form-submit modes the widget owns the lifecycle and these methods are not exposed.
Lifecycle
%%{init: {'themeVariables': {'actorBkg':'#6a4c93','actorBorder':'#3d2a5e','actorTextColor':'#fff'}}}%%
sequenceDiagram
participant W as Widget
participant C as Cap
participant I as Iframe
participant P as Platform API
alt auto / form-submit mode (with game)
W->>W: connectedCallback / form submit
W->>P: GET /games/:id/resolve
P-->>W: { url, integrity }
W->>I: mount srcdoc iframe
W->>C: cap.solve()
W->>P: POST /game/start
I->>I: load & run game script
I-->>W: postMessage game-started
W-->>W: emit start event { gameId }
I-->>W: postMessage game-complete { score, durationMs }
W->>W: update platform { score, durationMs }
W->>C: releaseRedeemGate()
C->>P: POST /game/complete (redeem)
P-->>C: { powToken, wrappedToken }
C-->>W: solve() resolves
W->>W: assemble wrappedToken
W->>W: inject caputchin-token input
W-->>W: emit complete event
else invisible default (no game configured)
W->>W: connectedCallback
W->>C: cap.solve()
W->>P: POST /game/start
W->>W: releaseRedeemGate() immediately
W-->>W: emit start event { gameId: null }
C->>P: POST /game/complete (redeem)
P-->>C: { powToken, wrappedToken }
C-->>W: solve() resolves
W->>W: assemble wrappedToken (score: null)
W->>W: inject caputchin-token input
W-->>W: emit complete event
else manual mode
W->>W: widget.start() called by customer
W->>C: cap.solve()
W->>P: POST /game/start
W-->>W: emit start event { gameId: null }
Note over W,I: No iframe. Customer drives game UX in host page.
W->>W: widget.complete({ score, durationMs }) called by customer
W->>W: update platform { score, durationMs }
W->>C: releaseRedeemGate()
C->>P: POST /game/complete (redeem)
P-->>C: { powToken, wrappedToken }
C-->>W: solve() resolves
W->>W: assemble wrappedToken
W->>W: inject caputchin-token input
W-->>W: emit complete event
end
Note over W,P: Token locked at first completion. Subsequent rounds record score only.
The widget defers expensive work until the user actually engages. Cap's solve() is not called on widget mount — it fires when the game starts, so users who never play don't generate PoW challenges. Game code runs inside a sandboxed cross-origin iframe mounted by the widget; the widget and the iframe communicate via postMessage. See ADR-0015 for the isolation model.
- Mount — render container in the host page, no network until the trigger fires. The iframe is not yet built.
- Trigger — depends on
mode. Inauto(default), the trigger is mount itself. Inform-submit, it is the enclosing<form>submit event (which is gated until completion). Inmanual, it is the customer'swidget.start()call. On trigger the widget resolves the game ID via/games/:id/resolve(for marketplace IDs) or uses the customer-suppliedgame-src(for customer-private games), builds the iframesrcdocwith a path-pinnedscript-srcCSP, mounts the iframe, and calls Capsolve()plus/game/startin the host realm. Inmanualmode, no iframe and no game are loaded; only Cap runs. - Game runs inside the sandboxed iframe (non-manual modes with
game/game-srcset) — opaque origin, no access to the customer's cookies,localStorage, DOM, or same-originfetch. The iframe loads the game script via<script src>(with SRI for marketplace games); the game's top-levelregister()call writes into the iframe'swindow.Caputchin.games. The iframe runtime invokes the registered factory with acontainerelement and a push-onlybridge— see game-sdk. In the invisible default (no game configured), no iframe is mounted at all — Cap runs alone. - Completion — for iframe-hosted games, the iframe relays
bridge.complete({ score, durationMs })to the widget viapostMessage. Formanualmode, the customer callswidget.complete({ score, durationMs })directly. For the invisible default, Cap completing is itself the completion; the widget builds the wrapped token withscore: null. In all cases the widget releases the held CapredeemviaCAP_CUSTOM_FETCH, which fires/game/complete. - Wrapped token returned — widget drops
caputchin-tokeninto the form and emits thecompleteevent. - Subsequent completion calls (replay, multi-round) — widget calls
/game/completeagain for scoreboard recording, but the wrapped token is already locked. - Optional 3-letter nickname prompt (scoreboard-enabled site keys only, Post-MVP) — surfaced after the first completion as a retro-style 3-character entry rendered by the widget in the host realm, not by the game inside the iframe. Non-blocking: the wrapped token and the
completeevent have already fired by this point, so the customer's form submission is unaffected. Widget posts the entry to/game/nickname; the user may close the tab and the score persists anonymously. Game authors are not involved — the widget owns this surface end-to-end. See ADR-0014.
Game loading
The widget resolves the game ID to a script URL and loads it inside the iframe — never in the host page realm. Two paths:
- Marketplace ID (e.g.,
@cooperative-games/bubble-pop) — widget calls/games/:id/resolveand receives{ url, integrity }. The iframe loads the script via<script src integrity>. - Customer-hosted game — customer passes a
game-srcattribute pointing at a script they host (their static-asset directory, any URL). The iframe loads that URL. No integrity hash because no canonical hash exists for customer-private code; the customer's own deploy pipeline is the trust root.
In both cases the game's top-level register() call writes into the iframe's own window.Caputchin.games — there is no host-page registry to write to. See game-distribution and ADR-0015.
Pool selection (games=)
When games="a,b,c" is set, the widget picks one ID at random per session. There is no server-side pool config, no allowlist, no game-pool lock — by design. See principles and ADR-0006 for the related "no fake config knobs" stance.
Isolation
The widget mounts every game inside an <iframe sandbox="allow-scripts" srcdoc="...">. Sandbox without allow-same-origin gives the iframe an opaque origin distinct from the customer's page. The iframe's inline CSP path-pins script-src to exactly the runtime URL plus the resolved game URL — no wildcards. Customer-side CSP is not affected by what runs inside the sandbox; customers do not need to allowlist jsDelivr or the game origin in their own policy. See ADR-0015 for the full threat model and the rationale for srcdoc over a dedicated subdomain.
Mobile
The widget works inside a WebView with no special configuration. The sandboxed iframe is supported across iOS and Android WebView builds in the project's support matrix. See mobile for the embed pattern.
Cap masking inside the widget
Runtime traffic (PoW challenge / redeem, /siteverify) originates from caputchin.com domains. The widget script itself loads from jsDelivr under the @caputchin/widget package name; Cap's WASM is inlined as base64 inside cap.min.js (pre-1.0), so widget.js is a single self-contained file — see ADR-0016 and cap-integration for the full masking posture.