Builder docs

Operating the platform

For operators: the components, bring-up order, and games-host configuration.

This page is for the platform operator, not the game builder. Builders never run any of this — it exists so the whole picture lives on one site. The full step-by-step runbook is docs/RUN-FROM-SCRATCH.md in arcade-platform; this page is the map of what you are running and the configuration that trips people up.

The components

ComponentRepoWhat it does
Host GSP (arcaded)arcade-platformThe on-chain referee: one process, game-code-free, runs every game's rules.wasm in the embedded wasmtime judge. Follows the chain through XayaX.
XayaXxaya/xayaxThe Polygon→GSP move bridge the GSP consumes.
Relayxaya-relayThe WebSocket transport that carries every game's channel proofs.
games-hostarcade-platformServes registered UI bundles and each one's arcade-config.json. GAMES_MODE=path (what the public plane runs) mounts every game at /g/<slug>/ on one games origin — a hostname of its own unless the operator sets GAMES_SAME_ORIGIN_FIRST_PARTY=1, which both public planes do; GAMES_MODE=port gives each game its own origin. See Hosting & registration.
submissionsarcade-platformSelf-serve upload + moderation queue; Accept runs the go-live steps (on-chain reg, registerBundle, the shell content row). Runs on the playground with AUTO_ACCEPT=1 behind /attach; not deployed on arcade.xaya.io, where Submit is hidden and /api/submissions answers 503.
The shell (this site)xaya-arcadeWallet, identity, game library, embeds, the /submit and /admin/submissions UIs.
ArcadeWagerarcade-wagerThe escrow contract — deploy once, registerGame per wagered game.

Bring-up order: chain access (Polygon RPC) → XayaX → GSP → relay → games-host → submissions → shell. A fresh GSP needs a start height set to roughly the current chain head minus 100, or it tries to sync from the compiled-in genesis and never catches up (an existing datadir wins over the variable).

games-host configuration

Read at startup (games-host/src/server.ts); the host refuses to start when a required one is missing:

VariableRequiredMeaning
ARCADE_ORIGINyesThe shell origin allowed to embed the games.
GSP_URL, RELAY_URL, CHAIN_RPC_URLyesFolded into every game's /arcade-config.json.
GAMES_MODEno (default port)Exactly two values, and anything else is a startup error: port = each game on its own origin at basePort+n (rigs); path = every game mounted at /g/<slug>/ on one games origin (what the public plane runs).
GAMES_PUBLIC_ORIGINin path modeThe hostname bundles are reachable on. The host refuses to start when it equals ARCADE_ORIGIN: a game framed from the shell's origin shares the shell's realm outright, whatever the iframe sandbox says. The shell's NEXT_PUBLIC_GAMES_FRAME_SRC names the same origin.
GAMES_SAME_ORIGIN_FIRST_PARTYno1 declares every hosted bundle first-party and lifts that refusal, in the host and in the shell alike, so the games are path-served from the shell's own origin. It gives up the browser boundary around a bundle, so it belongs only on a plane that hosts no third-party code; both public planes run with it.
GAMES_PUBLIC_HOSTin port modeThe host/IP games are reachable on, one port per game. Required only in port mode; path mode uses GAMES_PUBLIC_ORIGIN above instead.
GAMES_BASE_PORTno (default 8200)The control origin's port, and the base for port-mode slots.
GAMES_DATA_DIRno (default /data)The bind-mounted registry: manifest, bundles, extracted games.
DEV_WALLETnoFolded into arcade-config.json; enables the dev-wallet picker on a fork plane.

The control origin listens on GAMES_BASE_PORT (8200); in port mode games get 8201 upward by registration order, 19 slots. The manifest (manifest.json in the data dir) is { version: 1, basePort, games } — the host rejects an unknown version — and each row carries both a port and a basePath, the latter being what path mode serves from.

path mode is fail-closed and port mode is not, which is the one asymmetry worth remembering: a row whose bundle was registered without --bake has no baked mount, so path mode 404s it while port mode serves it happily at the slot root. A fork deployment therefore looks perfectly healthy while every game on the public plane is a 404. Nothing bakes for you on the CLI: --bake is an opt-in boolean flag on npm run register -w games-host (games-host/src/register.ts), and without it the row is registered with no basePath at all. The submissions service's Accept always bakes (its bake step, submissions/src/accept.ts) — hand registration is the path that can forget, which is why a deployment's own scripts should pass --bake explicitly.

Submissions service and the shell's runtime config

The submissions service accepts uploads, moderates them, and — on Accept — signs the on-chain registration, calls registerBundle, and writes the shell content row. It signs with an admin wallet (mounted read-only into the container) and shares the games-host data dir and the shell's runtime dirs.

VariableWhereMeaning
ADMIN_TOKENsubmissionsGates the admin endpoints (list / accept / reject / artifact); timing-safe compared.
GAME_IDsubmissionsThe namespace the reg/blob admin moves are sent as (xarc on the playground), not the game type.
GSP_URLsubmissionsPolled to confirm the registration landed (the move is fire-and-forget).
GAMES_DATA_DIRsubmissions + games-hostShared bundle registry the two write/read.
SITE_INTERNALsubmissionsThe shell URL, set on every plane that runs one. Set → Accept writes the shell content row and card media, then verifies /play/<slug> before marking a game live, and that is the accepted game's play URL. Unset → both are skipped and the play URL is the bare games-host mount /g/<slug>/, which is the only correct answer for a plane with no shell to write a row for.

The shell consumes three runtime variables (all server-side, never NEXT_PUBLIC_ — set them to reconfigure without an image rebuild). Each one is purely additive: unset, the site falls back to the baked image's own behaviour, so the same image runs a bare deployment and a full one. Both live planes set GAMES_CONTENT_RUNTIME_DIR and MEDIA_RUNTIME_DIR; only the playground also sets SUBMISSIONS_INTERNAL — that is how its auto-accept lists a game with no site rebuild, and why arcade.xaya.io's /api/submissions answers 503:

VariableMeaning
SUBMISSIONS_INTERNALThe submissions service base URL the /api/submissions proxy forwards to. Unset → those routes 503 (fail closed).
GAMES_CONTENT_RUNTIME_DIRThe overlay dir merged over the baked content rows (Accept writes here). Unset → baked rows only.
MEDIA_RUNTIME_DIRThe dir the /media/community/... route streams uploaded card art from. Unset → that route 404s.

Wagering

Deploy ArcadeWager once; per game follow the three-action enable chain in Hosting & registration — on-chain actions first, bundle flags last.

A queue row is retired by its own amount, not by its payee. The contract's replay key is keccak256(betAmount, numPlayers, matchId, payee, amount) — the same five fields the GSP keys a prepayment on. A payment at any other amount therefore marks a different key and leaves the row payable, so nobody can retire a row owed a whole pot by paying one wei of it. Anything that derives that key — the shell's snapshot builder, the SDK's paid-set read — must pass the row's own amount or it will read a still-payable row as settled and snapshot past the queue front.

A paid join names the payout group it consents to. The consent hash covers the match's stored queue snapshot, and those bytes do not change when some other lobby's fill consumes the group they lead with — so a join also sends the matchId of the group the contract's start-time walk would land on right now. If that group moves between reading and filling, the join reverts rather than paying whatever sits behind it. Seats after the first inherit the group the lobby already committed to; a lobby whose front has moved unwinds through leaveMatch/abortMatch/expireMatch with every stake refunded.

The contract does not own the GSP's g/ name. The operator wallet keeps it and grants the contract an ERC-721 approval over it, which is what lets _startMatch author the start move. That distinction matters twice over:

  • It is the emergency stop. Revoking that approval makes _startMatch revert, so no paid match can start, while creates and partial joins still unwind through abortMatch/expireMatch. There is no pause function and no transferOwnership on the contract — this is the only lever.
  • The name never leaves the operator wallet. The end-to-end suite hard-fails if it ever does, because a transferred name would strand every in-flight match.

Registry admin moves are authored under that same g/ name.

Payment queues are pooled across games. A queue is keyed (tier, num_players) with no game level, so one FIFO serves 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 — each start pays only the group at the front. 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. So enabling a game at a tier/seat combination nobody has used yet is a money decision, not just a listing one: it creates three operator-payable seed groups, and the first three paid matches there pay their pot to the operator as a one-time bootstrap fee. It also sets the concurrency ceiling — up to three paid matches in flight at one combo at once, with an abandoned match normally costing one unit of that only until the abandon reaper pushes its group back.

Two edges do not heal on their own: a channel the reaper never reaches (an open dispute is deliberately excluded from reaping) holds its group while it stays that way, and a paid start for a game with no registry row can never be re-seeded — no operator to pay, and a pooled queue has no other row's operator to fall back on — so that depth is lost for good with only a node-side warning to show for it. Neither is reachable through the normal flow, but a combo that has drifted shallower than it should be is repaired with the seed move below, not by re-sending a reg.

Deepening a live combo is the seed admin move — {"cmd":{"seed":{t,n,addr,burn,fee}}}, authored as g/<GAME_ID> — never another reg. It adds one group to an existing (tier, seats) queue and carries no amount; the GSP derives the queued amount from the tier, the seat count and the burn/fee parameters. That is the lever for raising concurrency at a busy combo.

The fork-testing rig

The operator's fork-testing rig — its deployment repository, not published — stands the whole stack up against a forked chain with funded test wallets, and is the operator-grade proving ground for everything above. Expect a 10–15 minute cold GSP build, and set evm_setIntervalMining 2 for timeout scenarios, since a local fork idles without minting blocks.

The hosted playground needs none of that: it mines a block every ~2 s while a game is in play and idles when nobody is, so dispute expiry, sudden death and a seat released by leaving all come due on their own while you watch. A dispute you file will expire on its own — there is nothing to arm and nobody to ask.

Registering games into a running platform: Hosting & registration.