# host-game

> Online page: https://cassette.gg/agents/host-game.md. Fetch it again before you act on it; don't work from a saved copy.
> Start of every task: https://cassette.gg/agents/guide.md.

Goal: the build is live in the developer's own hosting account at an HTTPS play URL, Cassette may frame it, and its build hash can
be verified. Cassette never hosts, pins, caches, proxies, mirrors or pays for the build: it only indexes a pointer (the play URL, or
`ar://<id>` on Arweave once it opens, plus the build hash). Everything below uses the providers' own tools (pinned versions, run through `npx`), `gh`, `git`,
`curl`, `jq`, `sha256sum` and GNU `find`/`cp`.

**Credentials stay on this machine.** Logins live in the providers' tools; tokens (`CLOUDFLARE_API_TOKEN`, `NETLIFY_AUTH_TOKEN`, `VERCEL_TOKEN`,
`PINATA_JWT`, the agent's key) come from the environment. Never send them to Cassette, never put them in the game folder, never print them, never
pass a token or key as a command-line argument (process lists show arguments; use the environment or a file read by the tool).

## 0. Conventions

```sh
GAME=/abs/path/to/dist            # the BUILT folder: index.html, cassette.json, assets
API=https://cassette.gg                   # the platform API
cd "$(mktemp -d)"                 # run the providers' tools from an empty folder: no wrangler.toml, netlify.toml, functions/ or .vercel/ can interfere
```

Pinned versions (use exactly these): `wrangler@4.153.0`, `netlify-cli@27.14.3`, `vercel@65.4.0`, `@ardrive/turbo-sdk@2.1.2` (its command is `turbo`).
`gh`: any recent release (these flags are from 2.103.2).

## 1. Hosts

| Host | Play URL | Same URL on every deploy | Response headers | Deploy tool | The human's one step |
|---|---|---|---|---|---|
| Cloudflare Pages (default) | `https://<project>.pages.dev/` | yes | `_headers` file | wrangler | approve a device code |
| Netlify | `https://<site>.netlify.app/` | yes | `_headers` file | netlify-cli | approve a login URL |
| Vercel | `https://<project>.vercel.app/` | yes | `vercel.json` | vercel | approve a device code |
| GitHub Pages (user site) | `https://<owner>.github.io/` | yes | none (no X-Frame-Options, CORS `*` by default) | gh + git | approve a device code |
| Own server | your origin | yes | your server config | rsync/scp | none |
| Arweave (**coming soon**, not open yet) | `https://<base32(id)>.arweave.net/<id>/` | **no**: stable game id; the play URL changes per build | none (gateways send no X-Frame-Options/CSP, CORS `*`) | turbo | 0 when the build fits the free tier, otherwise one top-up (card or POL) |
| IPFS (mirror only) | `https://<cid>.ipfs.dweb.link/` | no: a new CID per build | the gateway's | curl to Pinata | a Pinata key |

Technical facts:

- `/.well-known/` must be at the origin root. GitHub Pages: only the user site (`<owner>.github.io`, one game at its root) or a custom domain has it;
  a project site (`<owner>.github.io/<repo>/`) does not, so it only suits a game that sells nothing (no seller record).
- A game that sells needs its seller record and documents at one stable place: on IPFS the address changes per build, so IPFS is a mirror only.
  Arweave (coming soon) will work because the seller record and each document are their own permanent items (`ar://<id>`).
- Cloudflare Pages: 20,000 files, 25 MiB per file. GitHub Pages: 100 MiB per file, 1 GB per site.
- **Arweave (coming soon)** gateways cannot send headers or encodings, so these games need another host: cross-origin isolation (COOP/COEP in `_headers` or
  `vercel.json`, threaded Unity or Godot exports, `SharedArrayBuffer`), precompressed `.br`/`.gz` assets or `Content-Encoding`. Use relative asset
  paths (`./assets/…`; root-absolute `/assets/…` only works on some gateways). Above about 50 MB, gateways load slowly and storage costs grow.
- **Saves on Arweave (coming soon):** each build has a new origin, so `localStorage` and IndexedDB start empty after an update. Keep saves in the Cassette
  shell's saves (bridge `{type:'saves'}`, `https://cassette.gg/agents/add-paywall.md` §4), which survive builds. Keep no secrets in `localStorage`.

## 2. Detect first

Prefer a host the human already uses. Ask each tool (read-only, nothing prompts):

```sh
npx -y wrangler@4.153.0 whoami --json            # logged in: {"loggedIn":true,"authType":"OAuth Token","email":"dev@example.com","accounts":[…]}
                                                  # not:       {"loggedIn":false} (exit 1)
npx -y netlify-cli@27.14.3 status --json         # logged in: {"loggedIn":true,"account":{"Name":"Dev","Email":"dev@example.com",…},…} (exit 1 outside a linked folder: read the JSON)
                                                  # not:       {"loggedIn":false,"error":{"code":"NOT_LOGGED_IN",…}}
npx -y vercel@65.4.0 whoami --json               # logged in: {"username":"dev","email":"dev@example.com","team":…}
                                                  # not:       {"loggedIn":false,…} (exit 1)
gh auth status --hostname github.com --json hosts --jq '.hosts["github.com"][]? | select(.active) | {login, scopes}'
                                                  # logged in: {"login":"octo","scopes":"gist, read:org, repo, workflow"}; not: nothing
env | grep -oE '^(CLOUDFLARE_API_TOKEN|NETLIFY_AUTH_TOKEN|VERCEL_TOKEN|GH_TOKEN)=' || true    # names only, never values
```

Use the first that is ready, in this order: Cloudflare Pages, Netlify, Vercel, GitHub Pages (needs the `repo` scope; `gh` not installed = not
ready). None ready: Cloudflare Pages, and the human approves one device login (step 3). Use another host only when the developer asks for it.

## 3. Device logins (no localhost callback: works in containers and over SSH)

Start the login in the background, read the URL and code from its output, give them to the human, then wait for the tool to say it is logged in.

```sh
nohup npx -y wrangler@4.153.0 login --device --browser=false > /tmp/cf-login.log 2>&1 < /dev/null &
sleep 20; grep -A3 'please visit' /tmp/cf-login.log
#   To authorize Wrangler, please visit: https://dash.cloudflare.com/oauth2/device
#   and enter the code: ABCD-1234
#   You have 5 minutes to approve this request.
```

| Host | Start | Output to read | Done when |
|---|---|---|---|
| Cloudflare | `npx -y wrangler@4.153.0 login --device --browser=false` (background) | `please visit: <url>` and `enter the code: <code>` (5 minutes) | `whoami --json` says `"loggedIn":true` |
| Vercel | `npx -y vercel@65.4.0 login` (background) | `Visit <url>` (the URL carries `user_code=<code>`) | `whoami --json` has a `username` |
| GitHub | `gh auth login --hostname github.com --git-protocol https --web --scopes repo` (background) | `one-time code: <code>` and `https://github.com/login/device` (15 minutes) | `gh auth status` lists the login with `repo` |
| Netlify | `npx -y netlify-cli@27.14.3 login --request "Cassette: host <slug>" --json` (returns at once) | `{"ticket_id":"…","url":"https://app.netlify.com/authorize?…"}` | `npx -y netlify-cli@27.14.3 login --check <ticket_id> --json` says `{"status":"authorized"}` (`pending`: wait; `denied`: start again) |

Tell the human exactly: "Open `<url>` on any device (your phone works), enter `<code>` and approve. You have 5 minutes. Create a free
`<Cloudflare|Vercel|GitHub|Netlify>` account in that same page if you don't have one." Then stop and wait for them. Poll the "done when" command
every few seconds; if the code expired (the background process ended and the tool is still logged out), start again and send the new URL and code.
Token alternative (no human step): `CLOUDFLARE_API_TOKEN` (+ `CLOUDFLARE_ACCOUNT_ID`), `NETLIFY_AUTH_TOKEN`, `VERCEL_TOKEN`, `GH_TOKEN` in the
environment.

## 4. The build hash and `.well-known/cassette-build.json`

Every host serves `/.well-known/cassette-build.json`: the list of the build's files with their sha256. Its own sha256 is the **build hash** you
register. The exact rules:

- **Files:** every regular file under the build folder, recursively, except anything whose name starts with `.` (so `.well-known/`, `.git/`,
  `.env`, `.cassette/` and every dotfile at any depth), anything named `node_modules` or `Thumbs.db` (any depth), symlinks, and these paths at the
  root: `_headers`, `_redirects`, `_routes.json`, `_worker.js`, `netlify.toml`, `vercel.json` (host config), `seller.json`, `legal/adoption.json`
  (private). `cassette.json` is included.
- **Order:** by path, byte order (`LC_ALL=C sort`).
- **JSON:** `{"version":1,"files":[{"path":"<relative/path>","sha256":"<64 hex>","size":<bytes>},…]}`, keys in exactly that order, no
  whitespace, no trailing newline. Paths use `/`, without `./`.
- **Build hash:** `0x` + sha256 of exactly those bytes.

```sh
# recipe: build-hash (run in "$GAME"; writes .well-known/cassette-build.json and prints the build hash)
set -euo pipefail
mkdir -p .well-known
find . -mindepth 1 \( -name '.*' -o -name node_modules -o -name Thumbs.db \) -prune -o -type f -printf '%P\0' \
  | grep -zvxF -e _headers -e _redirects -e _routes.json -e _worker.js -e netlify.toml -e vercel.json \
      -e seller.json -e legal/adoption.json \
  | LC_ALL=C sort -z \
  | while IFS= read -r -d '' f; do
      jq -nc --arg path "$f" --arg sha256 "$(sha256sum < "$f" | cut -d' ' -f1)" --argjson size "$(wc -c < "$f")" \
        '{path: $path, sha256: $sha256, size: $size}'
    done \
  | jq -sjc '{version: 1, files: .}' > .well-known/cassette-build.json
echo "0x$(sha256sum < .well-known/cassette-build.json | cut -d' ' -f1)"
```

Expected: one line, `0x` + 64 hex (e.g. `0x3936a314…218f8d`). Run it again after any change to the build. `cassette.json` is hashed and served (the
paywall reads its storefront pointer from it): never write the play URL or mirrors into it.

Then stage what the host gets: the hashed files plus `.well-known/`, never anything private.

```sh
# recipe: stage (run in "$GAME"; prints the staging folder)
set -euo pipefail
STAGE="$(mktemp -d)/site"
mkdir -p "$STAGE"
{ find . -mindepth 1 \( -name '.*' -o -name node_modules -o -name Thumbs.db \) -prune -o -type f -printf '%P\0'
  if [ -d .well-known ]; then find .well-known -type f ! -name '.*' -print0; fi; } \
  | grep -zvxF -e seller.json -e legal/adoption.json \
  | xargs -0 -r cp --parents -t "$STAGE"
echo "$STAGE"
```

Upload `$STAGE` (not `$GAME`) everywhere below. Its host config files (`_headers`, `vercel.json`) come from step 5.

## 5. Headers

Cassette frames the game from its web origins: `https://cassette.gg`, its subdomains, and the platform's `webUrl` (a different origin only in
development).

```sh
# recipe: frame-ancestors (prints the Content-Security-Policy value)
WEB=$(curl -fsS "$API/v1/config" | jq -r '.webUrl // empty')
WEB_ORIGIN=$(printf '%s' "$WEB" | sed -E 's#^(https?://[^/]+).*#\1#')
FA="frame-ancestors 'self' https://cassette.gg https://*.cassette.gg"
case "$WEB_ORIGIN" in https://cassette.gg | https://*.cassette.gg | '') ;; *) FA="$FA $WEB_ORIGIN" ;; esac
echo "$FA"
```

Expected: `frame-ancestors 'self' https://cassette.gg https://*.cassette.gg`. Send no `X-Frame-Options`. `/.well-known/*` needs
`Access-Control-Allow-Origin: *` and `Cache-Control: no-cache`; `/legal/*` needs `Access-Control-Allow-Origin: *` (the review page and the wallet
sheet fetch the documents).

**Cloudflare Pages, Netlify** (`$STAGE/_headers`; add your own rules after it if you have any):

```sh
# recipe: _headers (needs $FA and $STAGE)
cat > "$STAGE/_headers" <<EOF
/*
  Content-Security-Policy: $FA
/.well-known/*
  Access-Control-Allow-Origin: *
  Cache-Control: no-cache
/legal/*
  Access-Control-Allow-Origin: *
EOF
```

**Vercel** (`$STAGE/vercel.json`; merge into an existing one):

```sh
# recipe: vercel.json (needs $FA and $STAGE)
jq -n --arg fa "$FA" '{headers: [
  {source: "/(.*)", headers: [{key: "Content-Security-Policy", value: $fa}]},
  {source: "/.well-known/(.*)", headers: [{key: "Access-Control-Allow-Origin", value: "*"}, {key: "Cache-Control", value: "no-cache"}]},
  {source: "/legal/(.*)", headers: [{key: "Access-Control-Allow-Origin", value: "*"}]}]}' > "$STAGE/vercel.json"
```

**nginx** (inside the game's `server { }`; nginx drops server-level `add_header` inside a `location` that has its own, so repeat the CSP):

```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg" always;
location /.well-known/ {
    add_header Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg" always;
    add_header Access-Control-Allow-Origin "*" always;
    add_header Cache-Control "no-cache" always;
}
location /legal/ {
    add_header Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg" always;
    add_header Access-Control-Allow-Origin "*" always;
}
```

**Apache** (`.htaccess` at the game root, `mod_headers`, `AllowOverride FileInfo`):

```apache
<IfModule mod_headers.c>
  Header always set Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg"
  Header always unset X-Frame-Options
  <If "%{REQUEST_URI} =~ m#^/\.well-known/#">
    Header always set Access-Control-Allow-Origin "*"
    Header always set Cache-Control "no-cache"
  </If>
  <If "%{REQUEST_URI} =~ m#^/legal/#">
    Header always set Access-Control-Allow-Origin "*"
  </If>
</IfModule>
```

**GitHub Pages:** no headers possible, none needed (it sends no X-Frame-Options and CORS `*`); add an empty `$STAGE/.nojekyll` so `.well-known/`
is published. **Arweave:** none possible, none needed.

## 6. Deploy

Each example assumes `$STAGE` from step 4 with the headers file from step 5, and `NAME` = the game's slug (lowercase `a-z 0-9 -`).

**Cloudflare Pages**

```sh
npx -y wrangler@4.153.0 pages project create "$NAME" --production-branch main    # first time; "already exists [code: 8000002]" is fine
npx -y wrangler@4.153.0 pages deploy "$STAGE" --project-name "$NAME" --branch main --commit-dirty=true
#   ✨ Deployment complete! Take a peek over at https://4f2a9c1e.<subdomain>.pages.dev
```

Play URL: drop the first label: `https://<subdomain>.pages.dev/` (`<subdomain>` is `$NAME`, or `$NAME-xyz` when the name was taken).
Errors: "non-interactive environment … CLOUDFLARE_API_TOKEN" / "not authenticated" = step 3; "More than one account" = `export CLOUDFLARE_ACCOUNT_ID=<id>`.

**Netlify**

```sh
SITE_ID=$(npx -y netlify-cli@27.14.3 sites:list --json | jq -r --arg n "$NAME" '.[] | select(.name == $n) | .id')
[ -n "$SITE_ID" ] || SITE_ID=$(npx -y netlify-cli@27.14.3 sites:create --name "$NAME" --disable-linking --json | jq -r .id)
#   add --account-slug <team> when the account has several teams; a name taken by someone else gets a -NNN suffix (read .name)
npx -y netlify-cli@27.14.3 deploy --dir "$STAGE" --prod --no-build --site "$SITE_ID" --json
#   {"site_id":"…","site_name":"blob-run","deploy_id":"…","deploy_url":"https://<id>--blob-run.netlify.app","url":"https://blob-run.netlify.app",…}
```

Play URL: `https://<site_name>.netlify.app/`.

**Vercel**

```sh
npx -y vercel@65.4.0 deploy --cwd "$STAGE" --prod --yes --project "$NAME" --create-if-missing --json --non-interactive
#   add --scope <team-slug> to deploy to a team other than the tool's current one
#   {"status":"ok","deployment":{"id":"dpl_…","url":"https://blob-run-abc123-dev.vercel.app","productionUrl":"https://blob-run.vercel.app",…},…}
```

Play URL: `productionUrl` (`jq -r '(.deployment // .).productionUrl'`), plus `/`.

**GitHub Pages** (the user site `<owner>.github.io`, one game at its root)

```sh
OWNER=$(gh api user --jq .login)
REPO="$OWNER/$(printf '%s' "$OWNER" | tr A-Z a-z).github.io"
gh repo view "$REPO" --json name >/dev/null 2>&1 || gh repo create "$REPO" --public
gh api "repos/$REPO/contents/" --jq '.[].name' 2>/dev/null    # empty, or this game's files only; another game or a homepage = use another host
touch "$STAGE/.nojekyll"
cd "$STAGE" && git init -q -b main && git add -A \
  && git -c user.name="$OWNER" -c user.email="$OWNER@users.noreply.github.com" commit -qm "Cassette: $NAME" \
  && git -c credential.helper= -c 'credential.helper=!gh auth git-credential' push -q --force "https://github.com/$REPO.git" main
gh api -X POST "repos/$REPO/pages" -f build_type=legacy -f 'source[branch]=main' -f 'source[path]=/'    # "already enabled" (409) is fine
gh api "repos/$REPO/pages/builds/latest" --jq .status    # repeat every 5 s until "built" (about a minute); "errored" = see the repo's Pages settings
```

Play URL: `https://<owner lowercase>.github.io/`. Each deploy replaces the repository's content with one commit.

**Own server** (the developer's origin, with the step-5 nginx or Apache headers installed)

```sh
rsync -a --delete "$STAGE"/ deploy@games.example.com:/var/www/blob-run/     # or: scp -r "$STAGE"/. deploy@games.example.com:/var/www/blob-run/
```

**Arweave: not available yet** (coming soon; paid by the developer, never by Cassette). Kept here for when it opens:
don't host a game on Arweave today.

The developer pays: the Turbo free tier covers small items (up to 105 KiB each); otherwise the agent address's Turbo balance pays, topped up by the
human by card or with POL. Cassette never pays for or sponsors storage.

The agent signs with its own EVM key (the one it registers games with, publish-game.md), stored as a JSON string in a file outside the game
folder and readable only by you:

```sh
umask 077; mkdir -p ~/.config/cassette-agent
printf '"%s"\n' "$AGENT_PRIVATE_KEY" > ~/.config/cassette-agent/turbo-key.json     # "0x…" with quotes: the turbo tool reads it as JSON
KEY=~/.config/cassette-agent/turbo-key.json; ADDR=<the agent's 0x address>
TURBO="npx -y -p @ardrive/turbo-sdk@2.1.2 turbo"
$TURBO free-status --address "$ADDR" --token pol          # free-tier bytes left for this address
$TURBO price --value "$(du -sb "$STAGE" | cut -f1)" --type bytes    # Current price estimate for … bytes is ~0.0016 Credits
$TURBO balance --address "$ADDR" --token pol              # Turbo Balance for Native Address "0x…" / Effective Credits: 0.25
$TURBO upload-folder --folder-path "$STAGE" --index-file index.html --token pol --wallet-file "$KEY" --skip-confirmation > /tmp/ar-upload.txt
sed -n '/^Uploaded folder:/,$p' /tmp/ar-upload.txt | sed '1s/^Uploaded folder: //' | jq -r .manifestResponse.id
#   43 characters, e.g. xDk0q5dXMPN4fs2uPmn1D1cNrvwbePdLqo7ixMyQtUg: the manifest id. Register ar://<manifestId>.
```

`upload-folder` uploads every file in the folder (dotfiles included), so upload only `$STAGE`. It sets each file's `Content-Type` and builds the
`arweave/paths` manifest with `index.html` as its index. Play URL: `https://arweave.net/<manifestId>/` (it redirects to the build's own sandbox
origin `https://<base32(id)>.arweave.net/<id>/`).

```sh
# recipe: sandbox-label (prints the base32 label of $ID)
printf '%s=' "$ID" | tr '_-' '/+' | base64 -d | base32 | tr -d '=' | tr 'A-Z' 'a-z'
```

Balance too low (the upload fails with 402 / insufficient balance, or the price exceeds the balance): give the human both choices and stop:

- card ($5 minimum), a checkout crediting the agent address:
  `$TURBO top-up --address "$ADDR" --token pol --value 5 --currency usd` prints `Got Checkout Session {"url":"https://checkout.stripe.com/…",…}`
  (it then tries to open a browser and may exit with an error on a headless machine; the printed URL is what you send);
- POL: the human sends POL on Polygon to the agent address; when they say it arrived, and only then, convert it:
  `$TURBO crypto-fund --token pol --value <POL> --wallet-file "$KEY" --skip-confirmation` (a normal Polygon transaction from the agent's own key,
  paying its own gas). Credits can take a few minutes to land; check `balance`, then upload again.

What to tell the human: "Arweave storage for this build costs about $<x>, paid from the agent address `<addr>`'s Turbo balance. Choose one: pay by
card (minimum $5) at `<checkout url>`, or send `<n>` POL on Polygon to `<addr>` and tell me."

**IPFS mirror** (Pinata; the play URL changes every build, so never the primary host of a game that sells)

```sh
FORM=()
while IFS= read -r -d '' f; do FORM+=(-F "file=@$STAGE/$f;filename=$NAME/$f"); done \
  < <(cd "$STAGE" && find . -type f -printf '%P\0')
curl -fsS -X POST https://api.pinata.cloud/pinning/pinFileToIPFS \
  -H @<(printf 'Authorization: Bearer %s\n' "$PINATA_JWT") "${FORM[@]}" \
  -F 'pinataOptions={"cidVersion":1,"wrapWithDirectory":false}' -F "pinataMetadata={\"name\":\"$NAME\"}"
#   {"IpfsHash":"bafy…","PinSize":48211,"Timestamp":"…"}
```

Mirror URL: `https://<IpfsHash>.ipfs.dweb.link/` (a subdomain gateway, never `ipfs.io/ipfs/…`).

## 7. Preview (optional, no login)

A temporary deploy to show the human the game in seconds. It expires in about an hour and is **never registered with Cassette**. Run with the
tokens unset (`env -u CLOUDFLARE_API_TOKEN -u NETLIFY_AUTH_TOKEN -u VERCEL_TOKEN …`); a tool that is logged in may refuse or deploy into the account.

```sh
# Cloudflare (Workers static assets): a wrangler.jsonc next to the staged folder
printf '{"name":"%s","compatibility_date":"%s","assets":{"directory":"./site"}}\n' "$NAME" "$(date -u +%F)" > "$(dirname "$STAGE")/wrangler.jsonc"
(cd "$(dirname "$STAGE")" && npx -y wrangler@4.153.0 deploy --temporary)
#   Temporary account ready: … Claim URL: https://… ; then https://<name>.<account>.workers.dev
npx -y vercel@65.4.0 deploy --temporary --yes --json --non-interactive --cwd "$STAGE"
#   {"status":"ok","deployment":{"url":"https://…vercel.app","claimUrl":"https://…","expiresAt":…}}
npx -y netlify-cli@27.14.3 deploy --dir "$STAGE" --no-build --allow-anonymous --json
#   {"site_url":"https://….netlify.app","claim_url":"https://app.netlify.com/drop/…","password":"My-Drop-Site",…}
```

Wrangler's temporary deploy creates a temporary Cloudflare account and prints a notice about Cloudflare's terms; pass that notice on to the human
with the URL. Show the human the preview URL (and the Netlify password); for the real, stable URL deploy as in step 6.

## 8. Check with curl

`BASE` is the folder the build lives in: the play URL itself (`https://blob-run.pages.dev/`, or `https://arweave.net/<id>/` on Arweave: always under
`/<id>/`, never the gateway root).

```sh
BASE=https://blob-run.pages.dev/
curl -fsSL -o /dev/null -w '%{http_code}\n' "$BASE"                                     # 200
curl -fsSIL "$BASE" | grep -iE '^(x-frame-options|content-security-policy):' || true   # no X-Frame-Options; a CSP, if any, has frame-ancestors with https://cassette.gg
curl -fsSL "$BASE" | sha256sum                                                           # equals: sha256sum < "$GAME/index.html"
curl -fsSIL "${BASE}.well-known/cassette-build.json" | grep -i '^access-control-allow-origin: \*'
echo "0x$(curl -fsSL "${BASE}.well-known/cassette-build.json" | sha256sum | cut -d' ' -f1)"   # equals the build hash from step 4
```

All five as expected = done. A fresh Arweave upload can take a few minutes to reach every gateway: retry for about 2 minutes, then try
`https://ar-io.dev/<id>/` and `https://ardrive.net/<id>/`. A host that rewrites HTML (e.g. injected analytics) breaks the entry-hash line: turn
that off.

## 9. What to register

Register the **stable play URL** (`https://<project>.pages.dev/`, `https://<site>.netlify.app/`, `https://<project>.vercel.app/`,
`https://<owner>.github.io/`, your origin), or **`ar://<manifestId>`** for Arweave once it opens, together with the **build hash** from step 4 (and an IPFS mirror
URL if you made one). Never a preview URL, never a per-deployment URL. How to register it with the Cassette API: page **publish-game** (https://cassette.gg/agents/publish-game.md).

---

Code in this document (and `paywall.js`) is MIT-licensed: `SPDX-License-Identifier: MIT`, Copyright (c) 2026 Delegas
Labs, S.L. Copy and adapt it freely.
