---
type: explanation
---

# Game distribution

Two ways a game reaches the [widget](widget.md). 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](adr/0015-sandbox-game-iframe.md) for the isolation model.

## The two paths

| Path | How the script gets loaded | Discovery |
|---|---|---|
| **Marketplace** | Widget resolves the game ID via [`/games/:id/resolve`](api.md), receives `{ url, integrity }`, iframe loads `<script src integrity>` from jsDelivr | GitHub topic tag `caputchin-game`, indexed by the platform — see [marketplace](marketplace.md) |
| **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()`](game-sdk.md)). 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](adr/0015-sandbox-game-iframe.md#alternatives-rejected).

## 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](game-sdk.md) 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.ico` and 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](guides/publish-to-marketplace.md).

## 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 inline `Blob` URLs 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](guides/author-a-game.md).

## When no game is distributed

Two [widget modes](widget.md#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 reports `score: 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 via [`widget.start()` and `widget.complete()`](widget.md#programmatic-api-manual--explicit-modes). They own the UI and the game (if any). The widget loads no script and mounts no iframe. Setting `game` or `game-src` together with `mode="manual"` is a configuration error and emits an `error` event with code `invalid-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](adr/0015-sandbox-game-iframe.md).
