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 onbasePort(default 8200, the control origin) andbasePort+1 … basePort+19— 19 game slots (matchingEXPOSE 8200-8219). A slug's port is assigned by registration order (a new slug takesmax(usedPorts)+1; an existing slug keeps its port). -
path— what 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--bakeis 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 equalsARCADE_ORIGIN. A bundle framed from the shell's own origin is inside the shell's realm — sharing its DOM, its wallet plumbing and itslocalStorage— 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 CSPframe-srcnames the games origin rather than'self'. Operator side that means DNS, TLS and a route for one more hostname; the bundles'gspUrl/relayUrl/chainRpcUrlmust resolve from it too. The one override isGAMES_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:
registerGame(gameType, freeOnly, minPlayers, maxPlayers)on the deployed ArcadeWager contract (thearcade-wagerrepo), using the same wire id and seat range as the GSP registration.- The reg-move wagering block — the GSP registry row records the ArcadeWager game type it settles against (on-chain consensus state).
--wager-address 0x…on the bundle registration (step 2 above), pointing the row's--game-typeat 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_DIRunset 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 withMEDIA_RUNTIME_DIR; only the playground also setsSUBMISSIONS_INTERNAL, which is what lets an accepted game appear there with no rebuild. arcade.xaya.io leavesSUBMISSIONS_INTERNALunset, so its/api/submissionsanswers 503 and it has no self-serve listings at all.