Game distribution
Two ways a game reaches the widget. Both load the script into the same sandboxed iframe; the only difference is who hosts the file and how the widget gets the URL. See ADR-0015 for the isolation model.
The two paths
| Path | How the script gets loaded | Discovery |
|---|---|---|
| Marketplace | Widget resolves the game ID via /games/:id/resolve, receives { url, integrity }, iframe loads <script src integrity> from jsDelivr |
GitHub topic tag caputchin-game, indexed by the platform — see marketplace |
| Customer-hosted | Customer puts the game's built JS file in their own static-asset directory and passes the URL via the widget's game-src attribute; iframe loads <script src> from that URL |
Out-of-band; the customer chose the file and the URL |
In both cases the game executes inside the same sandboxed iframe with the same CSP shape (only the allowed script URL differs) and the same SDK contract (register()). The widget never runs game code in the host page realm.
Why two paths, not three
Earlier drafts described a third "bundled into customer app" path where the customer imported the game's npm package and the register() call ran inside the customer's bundle, writing to window.Caputchin.games[id] in the host realm. That path is no longer offered. It is incompatible with the iframe sandbox: bundled code already executes in the host realm by definition, so moving it into the sandbox would require either bundler magic on the customer side (the ?url import or equivalent) or wrapper tooling on the author side that produces a string-export. Both options trade real isolation properties for a convenience the customer can get more straightforwardly by hosting one file. The customer's static-asset directory already exists; one additional file is not new infrastructure. See ADR-0015.
Game IDs are just strings
IDs are validated for format only — length and character set. There is no enforced namespace. By convention:
- Marketplace IDs tend to look like
@org/repo(mirrors npm scope conventions). - Customer-hosted IDs can be anything the customer wants.
There is no central registry. There is no namespace ownership. There is no allowlist.
Collisions
A page mounts a single <caputchin-widget> element per verification, each pointing at one game (or a randomly-chosen one from games="..."). Because each iframe is independent and each game runs in its own opaque-origin realm, there is no shared window.Caputchin.games namespace across the page. Two widgets on the same page pointing at different games do not interfere. Within a single iframe, only one game script loads, so author-side IDs collide only if a single bundle accidentally calls register() twice — the SDK logs a console warning and last write wins, scoped to that iframe.
Why these two paths
- Marketplace — the discoverable default, lowest-effort for customers who want variety. The platform handles versioning and SRI; the customer adds a
<caputchin-widget>tag. - Customer-hosted — for customers who need offline / air-gapped / regulated environments, for those who want to host every asset themselves (compliance, CSP simplicity, no third-party CDN), and for customers running first-party games they built. They drop one JS file into the directory they already use for
favicon.icoand pass the URL.
The same game source can ship via both paths simultaneously without modification — same register() call, same bundle output. See publish-to-marketplace guide.
Bundle constraint
The iframe loads exactly one script URL per game. Everything the game needs at runtime must be embedded in that single file: sprites, sounds, fonts, WASM modules, source maps. Authors target a single self-contained bundle with their bundler (esbuild, rollup, vite, webpack) configured to inline assets as data URLs and disable code splitting. Patterns that do not work inside the sandbox:
- Path-relative
fetch('./sprite.png')— resolves against the script URL inside the iframe, returns 404 - Dynamic
import('./chunk.js')— same problem; no second URL is allowed by CSP new Worker('./worker.js')— same problem; workers must be spawned from inlineBlobURLs if the bundle inlines the worker source- External CDN fetches at runtime — blocked by
connect-src 'none'in the iframe CSP
These constraints apply equally to marketplace and customer-hosted games. See the author-a-game guide.
When no game is distributed
Two widget modes bypass game distribution entirely:
- Invisible default (no
game/game-src): the widget runs Cap-only verification with no game UI. No iframe mounts, no script URL is fetched, and the wrapped token reportsscore: null. This path is the right fit when the customer wants minimal user friction and does not care about the game-as-UX angle. - Manual mode (
mode="manual"): the customer drives the verification lifecycle from their own host page realm viawidget.start()andwidget.complete(). They own the UI and the game (if any). The widget loads no script and mounts no iframe. Settinggameorgame-srctogether withmode="manual"is a configuration error and emits anerrorevent with codeinvalid-config.
Manual mode is the deliberate opt-out from sandbox isolation — the customer's first-party code runs alongside the widget in the host page realm. Marketplace and customer-hosted distributions remain sandboxed and untrusted-by-default. See ADR-0015.