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…]

The games-host README is the reference for this command: the register CLI lists every flag (--deactivate <slug> takes a game down), declaring touch play explains --touch-from, and every registration is a full-row replace, so a flag left off clears what the row held: omitting --wager-address on a re-registration clears it.

Production runs games-host in path mode (serving modes): every game is mounted at /g/<slug>/ on a games origin separate from the shell's, and registration bakes that mount into the bundle (path baking).

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 && env TN_RPC=<fork rpc> TN_KEY=<keyfile> XBM_GAME_ID=<gameid> \
  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> \
    WAGER_ADDRESS=<the plane's ArcadeWager> \
  npx tsx scripts/register-rules.ts \
    --game-type <gameType> \
    --min-players 2 --max-players 4 \
    --wasm $GAME_REPO/blob/rules.wasm \
    --cfg-suffix <hex, or '' for none>)

It carries the human-scale windows (disputeBlocks 300, graceBlocks 150, timeoutBlocks 900: HUMAN_SCALE_WINDOWS in submissions/src/chain.ts) plus the bet ladder and split read off that wager contract, adding nothing of its own to the move. A paid registration without WAGER_ADDRESS is refused; --free-only registers the zero stake alone and needs no contract. --cfg-suffix is required: before sending anything, the registrar opens the blob at every seat count of the range under that suffix and refuses a range or suffix the referee would not start. 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 opened under it wedges part-filled: a channel builds its board when it fills, from the suffix it opened with, so registering the right one fixes only channels opened after it. 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, feeTo) on the deployed ArcadeWager contract (the arcade-wager repo), using the same wire id and seat range as the GSP registration. feeTo is who is paid the game's fee: the builder's address for a game taken in, the platform's for its own. The builder can move it later with setGameFeeRecipient, and so can the contract's owner.
  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.

One edge does not heal, and it is worth knowing because it is 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. An abandoned match gives its group back to its own players, so it needs no registry row, even for a game whose row is gone. 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 can be registered at any time, and it applies only to channels opened afterwards: each channel keeps the blob hash and the cfgSuffix it opened with, so it finishes under the rules it started with. If your client bundles one blob and refuses any other, let its open channels finish before you switch. 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 needs no 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. Which planes set these variables is in Operating.