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
| Component | Repo | What it does |
|---|---|---|
Host GSP (arcaded) | arcade-platform | The on-chain referee: one process, game-code-free, runs every game's rules.wasm in the embedded wasmtime judge. Follows the chain through XayaX. |
| XayaX | xaya/xayax | The Polygon→GSP move bridge the GSP consumes. |
| Relay | xaya-relay | The WebSocket transport that carries every game's channel proofs. |
| games-host | arcade-platform | Serves 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. |
| submissions | arcade-platform | Self-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-arcade | Wallet, identity, game library, embeds, the /submit and /admin/submissions UIs. |
| ArcadeWager | arcade-wager | The 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:
| Variable | Required | Meaning |
|---|---|---|
ARCADE_ORIGIN | yes | The shell origin allowed to embed the games. |
GSP_URL, RELAY_URL, CHAIN_RPC_URL | yes | Folded into every game's /arcade-config.json. |
GAMES_MODE | no (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_ORIGIN | in path mode | The 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_PARTY | no | 1 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_HOST | in port mode | The 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_PORT | no (default 8200) | The control origin's port, and the base for port-mode slots. |
GAMES_DATA_DIR | no (default /data) | The bind-mounted registry: manifest, bundles, extracted games. |
DEV_WALLET | no | Folded 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.
| Variable | Where | Meaning |
|---|---|---|
ADMIN_TOKEN | submissions | Gates the admin endpoints (list / accept / reject / artifact); timing-safe compared. |
GAME_ID | submissions | The namespace the reg/blob admin moves are sent as (xarc on the playground), not the game type. |
GSP_URL | submissions | Polled to confirm the registration landed (the move is fire-and-forget). |
GAMES_DATA_DIR | submissions + games-host | Shared bundle registry the two write/read. |
SITE_INTERNAL | submissions | The 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:
| Variable | Meaning |
|---|---|
SUBMISSIONS_INTERNAL | The submissions service base URL the /api/submissions proxy forwards to. Unset → those routes 503 (fail closed). |
GAMES_CONTENT_RUNTIME_DIR | The overlay dir merged over the baked content rows (Accept writes here). Unset → baked rows only. |
MEDIA_RUNTIME_DIR | The 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
_startMatchrevert, so no paid match can start, while creates and partial joins still unwind throughabortMatch/expireMatch. There is no pause function and notransferOwnershipon 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.