Playground — disposable chain, test WCHI, wiped without warning
Builder docs

Hosting & registration

The operator runbook: hosted bundles, the games-host, on-chain registration, and wagering.

This whole page is operator-only. Registering a game is an operator action — it uses the games-host CLI plus operator-controlled admin wallets. A builder hands over artifacts and facts (see Submitting your game); an operator runs everything below.

1. Export the bundle (builder produces, operator receives)

./scripts/build-export.sh --bundle
# → dist/bundle.tar.gz + dist/bundle.tar.gz.sha256

A deterministic, content-addressed tarball of the static export in out/, with a detached sha256 sidecar so the operator can verify the bytes. build-frontend-image.sh builds the standalone frontend image and prints the exact FRONTEND_IMAGE=… line for the platform's compose.

The bundle must be built with NEXT_PUBLIC_GAME_ID set to the target plane's shared move namespace — the gameId its /arcade-config.json serves: xarc on the playground, arcbeta on arcade.xaya.io — not the game's own GAME_KEY, or it is accepted but refuses to boot on the namespace mismatch — see Submitting your game.

2. Register the bundle with the games-host

npm run register -w games-host -- \
  --data <data-dir> \
  --slug <slug> \
  --game-id <gameId> \
  --game-type <gameType> \
  --title "<Display Title>" \
  --seats <min>..<max> \
  --file dist/bundle.tar.gz \
  --sha256 <bundle-sha256> \
  --bake \
  [--touch-from <game-checkout>] \
  [--wager-address 0x…]

Deactivate a slug with --deactivate <slug>. The register verifies the file against --sha256, extracts it, and writes the manifest. --bake is an opt-in flag with no default (games-host/src/register.ts): with it, --file/--sha256 are the slug-agnostic upload and its provenance sha, and the CLI verifies that sha, bakes the real /g/<slug> mount into a temp file, then registers the baked file under its baked sha with basePath stamped on the row. Without it the row carries no basePath at all — see the fail-closed note below. --game-type is the consensus game type the game's channels run under — the shell holds the frame to it, signing a channel move only against a channel of that type, read from the plane's host GSP through the shell's GSP_RPC_URL proxy — and --wager-address points that type at an ArcadeWager contract; omitting the address on a re-registration clears it (every field is a full-row replace).

--touch-from is how the row learns whether the game can be played by touch. It takes the checkout the bundle was built from, not a boolean: the answer belongs to the game's code, and the CLI reads that source for a touchControls or fullFrameOnTouch declaration. A bundle that states the fact about itself — an arcade-manifest.json at its archive root carrying a touch boolean — outranks the flag and needs neither. Pass neither and the row records no answer, which is not the same as a no: the shell warns a phone player on both, but only a real declaration ever makes a game phone-ready. Like every other field this is a full-row replace, so re-registering without it clears whatever the row held. See The SDK for what a game declares and what a player is shown.

Two serving modes, and production runs the second one. GAMES_MODE selects between them (games-host/src/server.ts):

  • port — one origin per game. The games-host listens on basePort (default 8200, the control origin) and basePort+1 … basePort+1919 game slots (matching EXPOSE 8200-8219). A slug's port is assigned by registration order (a new slug takes max(usedPorts)+1; an existing slug keeps its port).

  • pathwhat the public plane runs. One listener, one games origin, every game mounted at /g/<slug>/. No per-game DNS, no per-game CSP entry, and an accepted game is reachable the moment it lands. Path mode is fail-closed: a row whose bundle has not been baked to exactly its mount is a 404, where port mode would happily serve an unbaked row at the slot root. That asymmetry is why --bake is not optional at registration — omit it and everything looks correct locally while every game 404s in public.

    Path mode requires GAMES_PUBLIC_ORIGIN, the hostname the bundles are actually reachable on, and refuses to start when it equals ARCADE_ORIGIN. A bundle framed from the shell's own origin is inside the shell's realm — sharing its DOM, its wallet plumbing and its localStorage — no matter what the iframe's sandbox says, so serving the two from one hostname would make every gate in the bridge decoration. The shell drops such a manifest row for the same reason, and its CSP frame-src names the games origin rather than 'self'. Operator side that means DNS, TLS and a route for one more hostname; the bundles' gspUrl/relayUrl/chainRpcUrl must resolve from it too. The one override is GAMES_SAME_ORIGIN_FIRST_PARTY=1, honoured by the host and the shell alike: it declares every hosted bundle first-party and path-serves the games from the shell's own origin, giving up exactly that boundary — so it belongs only where no third-party bundle is hosted. Both public planes run with it.

The manifest carries both: port is still assigned in either mode, and basePath is what path mode serves from.

{
  "version": 1,
  "basePort": 8200,
  "games": {
    "<slug>": {
      "active": "<sha256|null>", "gameId": "<gameId>", "gameType": "<gameType>",
      "title": "…", "seats": { "min": 2, "max": 3 }, "port": 8201,
      "basePath": "/g/<slug>", "history": [ … ],
      "wagerAddress": "0x…"
    }
  }
}

The version: 1 field is required — the host rejects an unknown version.

3. Register on chain

Two admin moves, from the platform repo. First the blob move stores the rules body ({sha256, z}); then the reg move — a full-row replace of the game's registry row — references that hash and carries the seat range and cfgSuffix:

(cd $ARCADE_PLATFORM && npx tsx scripts/onchain-e2e.ts \
    --register-blob $GAME_REPO/blob/rules.wasm \
    --reg-game-type=<gameType> \
    --reg-min-players=2 --reg-max-players=3 \
    --reg-cfg-suffix=<hex or omitted>)

On a real-chain plane, do not use onchain-e2e.ts. It registers a fork deployment's short dispute windows (disputeBlocks 10, graceBlocks 15). The deploy-day registrar is scripts/register-rules.ts, the path the beta's games were registered with:

(cd $ARCADE_PLATFORM && env TN_RPC=<chain rpc> TN_KEY=<registrar keyfile> \
    GSP_URL=<gsp rpc> GAME_ID=<g/ namespace> \
  npx tsx scripts/register-rules.ts \
    --game-type <gameType> \
    --min-players 2 --max-players 4 \
    --wasm $GAME_REPO/blob/rules.wasm \
    [--cfg-suffix <hex>])

It carries the human-scale windows — disputeBlocks 300, graceBlocks 150, timeoutBlocks 900 (HUMAN_SCALE_WINDOWS, submissions/src/chain.ts) — plus the bet ladder and the 500/500 bps split, wholesale from that one production source, adding nothing of its own to the move. It reports OK only once the GSP has indexed the row.

The registration move is fire-and-forget. Every GSP-side rejection is a LOG(WARNING) invisible to the sender — a bad move looks identical to a good one from the caller's side. Verify by polling the GSP game state until the registry reflects the change:

curl -s -X POST $GSP_URL -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","method":"getcurrentstate","params":[],"id":1}' \
  | python3 -c 'import json,sys; \
      print(json.load(sys.stdin)["result"]["gamestate"]["gameregistry"]["<gameType>"]["rulesBlobSha256"])'

The cfgSuffix landmine. The host builds cfg = LE32(channel seed) ++ cfgSuffix and hands it opaquely to the blob. If your blob expects a suffix but the registry carries an empty one, the host skips the seed prefix and hands the blob 0 bytes — and every channel wedges part-filled, because the suffix is re-resolved on every join-fill. Register the exact bytes the blob expects, or an empty suffix only if the blob takes an empty config.

4. Enable wagering (optional, fails closed)

Wagering is off until three operator actions all land:

  1. registerGame(gameType, freeOnly, minPlayers, maxPlayers) on the deployed ArcadeWager contract (the arcade-wager repo), using the same wire id and seat range as the GSP registration.
  2. The reg-move wagering block — the GSP registry row records the ArcadeWager game type it settles against (on-chain consensus state).
  3. --wager-address 0x… on the bundle registration (step 2 above), pointing the row's --game-type at the contract, which the host folds into /arcade-config.json.

The frontend gate is action 3 alone: wagerConfigured() reads the wagerAddress from /arcade-config.json, so without it the lobby renders exactly as free play, byte-identically. Actions 1 and 2 are enforced on chain: skip either and the enable chain fails closed at the contract/GSP layer (stakes are refused, never half-taken) — but the staking UI will still render, so do the on-chain actions FIRST and flip the bundle flags last (and strip the flags first when disabling).

Enabling a stake tier or seat count nobody has used yet costs the operator the first three pots there. A payment queue is keyed (tier, num_players) with no game level, so one FIFO is pooled across every registered game — a 10-WCHI win in any game is paid, once it reaches the front, by a later 10-WCHI match in any game, one group per start. Each brand-new combo is seeded three operator groups deep, once ever, when the first reg move whose seat range reaches it is processed; a second game registering at an already-seeded combo seeds nothing. Those seed groups are payable to the operator, so the first three paid matches at a cold (tier, seats) combo pay their pot to the operator as a one-time bootstrap fee. Registering at a tier/seat combination that is already live carries no such cost. Three groups also sets the concurrency: up to three paid matches can be in flight at one combo at once, and an abandoned match normally costs one unit of that only until the abandon reaper pushes its group back, so it heals on its own.

Two edges do not heal, and both are worth knowing because both are silent. A channel the reaper never reaches — one carrying an open dispute is deliberately excluded from reaping, since a disputed channel is being contested rather than abandoned — holds its group for as long as it stays in that state. And a paid start belonging to a game with no registry row cannot be re-seeded at all: there is no operator to pay, and the queue is pooled, so no other row's operator is the right payee; that unit of depth is lost for good and only a node-side warning records it. Neither is reachable through the normal flow (opening a channel requires a bound blob, hence a row), but if a combo ever ends up shallower than it should be, the fix is the seed move below rather than another reg.

Deepening a live combo is the seed admin move, never another reg. Once a combo is seeded, no further registration adds to it; the operator's lever is {"cmd":{"seed":{t,n,addr,burn,fee}}}, authored as g/<GAME_ID>, which adds one group to an existing (tier, seats) queue. The move carries no amount — the GSP derives the queued amount from the tier, the seat count and the burn/fee parameters. Use it to raise concurrency at a combo busy enough that matches wait.

5. Upgrade and rollback

A blob or cfgSuffix change is rejected while any channel of that game type is open — the sanctioned upgrade point is zero open channels. In-flight channels pin their own blob hash at open, so they finish under the rules they started with. Rollback = re-register a prior hash: content-addressed bodies are never deleted, so an old blob is always still there to point back at.

6. The shell content row (runtime overlay — no rebuild)

A game appears in this site once its content row exists: /play/<slug> and the library read the registry, and a content/games/<slug>.json row (schema: content/games/_schema.json in the xaya-arcade shell repo) is what lists it.

A brand-new slug no longer needs a site rebuild. When the shell is run with GAMES_CONTENT_RUNTIME_DIR set (a bind-mounted dir), the registry merges rows from that dir over the baked ones — a baked row wins on a slug collision — and re-reads them live. Writing <slug>.json into the overlay dir (and any card image under MEDIA_RUNTIME_DIR/community/<slug>/, served at /media/community/...) makes the game show up within the registry's short refresh window. This is exactly what the submissions service's Accept writes; the operator does not hand-edit it.

With GAMES_CONTENT_RUNTIME_DIR unset the shell reads only the baked rows, exactly as before — the overlay is a runtime add-on to the shipped image, not a change to it. Both live planes set it, along with MEDIA_RUNTIME_DIR; only the playground also sets SUBMISSIONS_INTERNAL, which is what lets an accepted game appear there with no rebuild. arcade.xaya.io leaves SUBMISSIONS_INTERNAL unset, so its /api/submissions answers 503 and it has no self-serve listings at all.