Ship a game on the Xaya Arcade
Build and test a channel game, then submit it for operator review.
0. Authority, where every claim in this file comes from
| Question | Authority |
|---|---|
| The judge ABI: which exports, which caps, what a trap means | arcade-platform/engine/judge/wasm_judge.cpp (the register gate's kRequiredFuncs list is the ABI, in code) |
| The same contract, written out, with the execution limits | arcade-platform/docs/ARCADE-ABI.md |
| Every submission bound: sizes, patterns, seat clamp, reserved names | arcade-platform/submissions/src/preflight.ts |
| How a bundle is mounted and what a base path means | arcade-platform/games-host/src/{bake.ts,handler.ts,server.ts} |
| Public builder documentation | https://arcade.xaya.io/docs: public, no login (source: xaya-arcade/content/docs/) |
Read that last one first. Four of the five authorities above are paths inside
arcade-platform; the site's /docs carries the parts you need soonest, in readable form:
| You need | Public page |
|---|---|
| The tooling versions | https://arcade.xaya.io/docs/requirements |
The clone command, npm ci, and a tour of the tree | https://arcade.xaya.io/docs/getting-started |
The full arcade_* signatures and their sentinel values | https://arcade.xaya.io/docs/rules-blob |
GameAdapter, configureApp, the presentation declaration | https://arcade.xaya.io/docs/sdk |
This skill summarizes the builder workflow. Verify implementation details against the authority files. Numeric claims must name their authority in the same sentence.
1. What the Arcade is: start here
One shared host GSP runs every game as a sandboxed rules.wasm blob under one referee, and one
shell hosts every game UI as a hash-verified static bundle. You write rules (C++ → wasm) and a
board UI (React). Reuse the SDK for wallet and channel operations. To play your build in the real
shell before you submit, attach it to the public playground at https://test-arcade.xaya.io/attach
(§3 step 4). The services behind it are in arcade-platform (with the shell in xaya-arcade and the
relay in xaya-relay). The public arcade's operator reviews and registers accepted games.
Submit a tested source commit through a GitHub issue. Open the Game submission form at
https://github.com/xaya/arcade-submissions/issues/new?template=game-submission.yml.
It asks for the repository, full commit covering frontend and rules, compiled WASM path and
SHA-256, game/registration details, test results and a wagering request with the Polygon address
your game's fee is paid to. Keep the rules source, compiled WASM, its hash and reproducible build
instructions in the same game repository.
The form is defined in arcade-submissions/.github/ISSUE_TEMPLATE/game-submission.yml.
The operator rebuilds the frontend for the target arcade, verifies the WASM hash, reviews the source and tests on a private playground before listing the game. No production frontend bundle hash is required. Running an arcade server is optional; do not make access to deployment tooling a prerequisite for preparing a submission. Click Submit new issue and follow that issue for review questions. For an update, use the same form with a new tested commit and the earlier issue.
Acceptance is discretionary either way: a human reads your repo before your game goes on the shelf,
so if the repo is private, say so and grant access. Public self-serve is deliberately not open.
The public arcade serves bundles at /g/<slug>/ on a games hostname separate from the shell.
The operator's private test plane uses the same separation. The optional same-origin override
in §5 is disabled on those deployments. Games on a shared games origin must still namespace
storage and use relative asset paths. A name is registered only when the operator accepts it.
No key and no funds. Building, testing and the run on the public playground need no wallet, no
p/ name and no WCHI: the playground shell's test wallets are funded for you; you only need those
to play on the real arcade.
Then: read §3 step 0, pick a slug and a gameType. There is no name-probe endpoint. Nothing on
the arcade will tell you in advance whether a name is free, and nothing reserves one for you; the
names are checked for the first time when the operator runs pre-flight over your submission. The rules
those checks apply - the reserved slugs, the slug and gameType patterns, the seat bounds, the
cfg-suffix cap - are all in arcade-platform/submissions/src/preflight.ts; §3 step 0 names
RESERVED_SLUGS. Read them there before you pick: the public playground's attach applies them,
against that plane's registry only.
2. The repos
The template is the starting point; the platform is the SDK and ABI reference. The shell and
deployment tooling are optional reading. The first-party games are a reading list: they live in this skill's EXAMPLES.md, one row each saying
what that game is the reference for.
| Repo | What it is | What you do with it |
|---|---|---|
| arcade-xayaman | THE copy-me template | Fork this one. Its README is the 10-section builder guide; §7 is the build/bundle/register run. |
| arcade-platform | the host GSP (arcaded), @xayaarcade/sdk, the games-host bundle server, the submissions service | Check it out so YOUR FORK's scripts/vendor-sdk.sh can re-cut the SDK tarball from it (ARCADE_PLATFORM=~/arcade-platform npm run vendor:sdk): that script lives in the template, not here. Read the banner it prints (§2.1). Also the ABI/judge reference. |
| xaya-arcade | the shell site (wallet, XAYA name, cross-game lobby, the closed submission front, embed + presentation) | Consumed, never forked. |
| arcade-wager | the deployed ArcadeWager.sol the arcade's stakes escrow into | Read-only, and only if you want to reason about payouts. Wagering is an operator switch, not game code - see WAGERING.md §1. |
What you actually need: a fork of arcade-xayaman - you only ever fork that one repo - a checkout of arcade-platform when updating the SDK or checking ABI source. The template already vendors its SDK.
Repo URLs and their access marks live in this skill's EXAMPLES.md. Do not assume visibility
from a name: the Arcade repos are public, but some older xaya/* repos cited for history are
private.
git clone https://github.com/xaya/arcade-xayaman.git
If a clone fails, stop and ask in #builders on Discord with
the repository name and the error you saw. Do not reconstruct the judge
ABI, the SDK surface or the submission contract from memory: every one of them is a consensus
surface, and a guess that compiles still fails at registration or, worse, at the first real match.
That Discord is also where to say so if your idea does not fit the hard constraints at
https://arcade.xaya.io/skills: say so before you build.
2.1 One clone of arcade-platform, and prove it is the right one
ARCADE_PLATFORM=~/arcade-platform is a default path, not a guarantee, and the vendored SDK is
the one thing in this flow where picking the wrong checkout produces a build that installs cleanly,
compiles cleanly and then fails at runtime: an older checkout at that path packs an older SDK, and
the game ships against a shell that has moved on.
The template's scripts/vendor-sdk.sh prints what it picked before it packs anything:
the resolved absolute path, the SDK version, the branch, the commit, and whether that checkout is
behind its upstream, and refuses outright when it is behind (override with
ALLOW_STALE_PLATFORM=1 only if you mean it). Read that banner every time:
==> SDK SOURCE <the absolute path it resolved>
sdk version <whatever that checkout's sdk/package.json says>
branch <branch>
commit <short sha>
freshness up to date with <upstream> | N commit(s) BEHIND <upstream>
| unknown: <why it could not check>
If the version or the branch is not the one you expect, stop: do not pack. freshness unknown
means the script could not reach the remote (no upstream, or offline); that is not a pass, it is a
"nobody checked". Keeping exactly one arcade-platform clone on the machine is the cheapest way
to never have this question.
Prerequisites
| Node | 20.9 floor, and use 22 - 20.9 is Next's own engines floor and 22 is what CI pins, so 22 is the only version every gate is actually proven on (https://arcade.xaya.io/docs/requirements) |
| Docker | required - the blob toolchain and the blob tests both run in containers. The template's builder image is not platform-pinned, so on an arm64 host you add the two linux/amd64 pins yourself before the blob will build at all: WASM.md §A2 names both |
| Python 3 | any: blob/check-blob.sh's structural gate is stdlib-only Python, deliberately, so that one rung runs even where Docker does not |
| git, curl | |
A Xaya p/ name and WCHI | not needed to build or to play on a playground. Only to play on the real arcade. Everything in §3 steps 1-5 runs with no account, no key and no funds |
3. The builder flow
Step 0: choose the four registration values
Four facts get registered on chain when your game is accepted. They are supplied outside your repo: the attach form (§3 step 4) and the submission you open (§3 step 6), and nowhere else. No file in your repo declares them, no build step reads them, and nothing validates them before a pre-flight: the playground's attach, or the operator's. Two of them are permanent once accepted and should be chosen before you write a line of code; two are provisional and you revise them freely while the rules settle.
| fact | permanence | when you fix it |
|---|---|---|
gameType (= your GAME_KEY) | burned forever once accepted. Globally unique across every game ever registered; nothing frees one (preflight.ts refuses any gameType the GSP registry or another pending submission carries) | now, before code |
slug (your site URL) | a one-shot claim in practice - changing it means a new slug and a dead URL | now, before code |
seats (min..max) | operator can re-register it; no self-serve path | provisional - settle it when your goldens do |
cfgSuffix | operator can re-register it, and a change applies only to channels opened afterwards: each channel keeps the blob and suffix it opened with. A wrong suffix wedges every channel opened with it; registering the right one fixes every channel opened after it. | provisional - derived from rules that do not exist yet (§3 step 2) |
When the names are checked: by pre-flight over your submission, and not one moment earlier. There are
exactly three name checks and they all live in preflight.ts:
- a
slugin the first-partyRESERVED_SLUGSlist (preflight.ts) is refused outright, and no token or exemption changes that. That list grows every time a first-party game ships, so read it in the source rather than trusting any count written down elsewhere; - a
slugalready held by a live game or another non-rejected queued submission is refused; - a
gameTypethe GSP registry carries, or another queued submission claims, is refused.
There is no availability endpoint and no way to ask in advance. A name is not yours until the
operator's pre-flight has passed it, and it is not permanent until the registration. A playground
proves none of this: the public one's attach checks the rules against that plane's registry only, and
its registry holds your games and the first-party ones, so a slug or gameType
that works there says nothing about the real registry; read RESERVED_SLUGS in preflight.ts
before you pick, and have a second gameType in mind (§3 step 4).
The relief valve, in the same breath, because you will want it: nothing is claimed until pre-flight sees the name and nothing is burned until an operator registers your game, so a submission that is turned down is fixed and reopened under the same slug and gameType. Only registration is terminal.
Keep the four values written down somewhere you will not retype them from memory: your fork's
README is the obvious place. Two of them already have a home in files you maintain and should be
copied from there, never retyped: gameType is GAME_KEY in src/app-identity.ts, and
cfgSuffix is the byte layout you record in your own blob/MANIFEST.md (§3 step 2). Get the two
permanent names right today; let seats and cfgSuffix converge as the rules settle, and reconcile
all four against your green run in step 5.
Step 1: fork the template and re-identify it
Fork arcade-xayaman and follow https://arcade.xaya.io/docs/make-it-yours. Two starting points:
(A) Re-identify only. A reskin over the shipped rules is a complete, submittable game. The identity is single-sourced, so this is a small closed edit.
(B) New rules. Do A, then replace the rules core, the codecs, the adapter/board UI and the e2e content.
The identity surface. The edits that fail silently if missed, and what the ladder reports
for the rest, are at https://arcade.xaya.io/docs/make-it-yours: do them in one edit, and expect the
ladder - not that list - to be what tells you when you are done. One fact that page does not carry:
the blob test image needs no edit. blob/tests/run-tests.sh names its image after the checkout
directory and tags it with the sha256 of blob/tests/Dockerfile.testenv, so two forks on one machine
never share an image and an edited Dockerfile is rebuilt on the next run.
The FOUR SDK entry points, and which one your shared code must use
This is the single most common way a new game repo fails to build, and it is not obvious from
the package name. @xayaarcade/sdk publishes four subpaths:
| import | for |
|---|---|
@xayaarcade/sdk | your app code - React screens, hooks, the bridge, wallet + chain. Needs a process global at import: it reads process.env.NEXT_PUBLIC_*, and its dev-wallet module does so at module scope, so a bundle with none (Vite, esbuild, a worker, a browser e2e harness) throws as soon as it imports the SDK. Next and Node provide it; any other bundler must define process.env. |
@xayaarcade/sdk/core | your shared code - BoardRules, your OpenChannel, your packed codec. Isomorphic: it evaluates in the browser bundle and under a bare npx tsx. |
@xayaarcade/sdk/e2e | node-only scenario infrastructure: on-chain moves, WsBroadcast, RecordingTxSender, quietChannelLogs(). |
@xayaarcade/sdk/server | node-safe serving pieces, e.g. buildSecurityHeaders: §3 step 4 has its contract, and you need it only if you host a build yourself. |
The rule: a file that both the Next bundle and the tsx e2e suite load may not import a runtime
value from the main entry. The main entry statically pulls in connectkit, whose exports map has
no default condition, so tsx dies with ERR_PACKAGE_PATH_NOT_EXPORTED and the whole scenario
suite fails at import: before a single assertion runs. Type-only imports are erased and are safe
from anywhere.
/core carries what shared code actually needs: the protobuf types, base64 + SHA-256, the channel
value types and wire constants (NO_TURN, WINNER_UNDECIDED, DRAW_WINNER, channelIdHex),
ChannelConfig, the dispute/resolution move bodies, and PackedJudge.
e2e/imports.test.ts enforces exactly that rule, and it fails closed. It reads package.json's
arcadeGameLibDir and takes that directory as a required sweep root, so a fork that renames
src/lib/<game>/ and that field together needs no edit here; its ALLOW set
contains no source file, because /core means no shared file needs an exemption to reach a runtime
value. Any sweep root with zero tracked .ts/.tsx files is a hard FAIL naming the root: an empty
root means the directory was renamed without the field, or its files are not yet git added. Still
read the file count it prints on PASS; a number that drops after a rename is the signal something
left the sweep.
Two ladder rungs need a real git repository, which is easy to miss if you started from a
downloaded copy rather than a clone: tests/repo/copyright-guard.test.ts and e2e/imports.test.ts
both shell out to git ls-files, and blob/check-blob.sh --rebuild wants a clean tree. git init
and commit once before running the ladder, and stage new files as you add them.
Path B also needs a frontend rename checklist, because the game directory is not the only place the template's own game leaks into a fork:
src/lib/games/<game>-adapter.ts: the adapter module and its exported symbol.src/bootstrap.ts: the adapter import and theregisterAdapter(GAME_KEY, …)call; the registry key isGAME_KEY, not the move namespace.- The board components and their CSS module names.
e2e/harness.ts's game imports and every game-shaped assertion in the scenarios.e2e/app-config.ts, which the whole e2e suite reads its identity from. (It imports from@/app-identityexactly as its header promises, so it usually needs no edit.)- The rest of
e2e/, which carries far more game shape than the two files above suggest.e2e/suite.tscontains a whole scenario written around the template's own mechanics plus board fields another game does not have;e2e/match-cfg.tsowns this game's cfg fields,encodeCfgSuffix/cfgSuffixHexandPROD_CFG_SUFFIX, and hands them to the SDK'sChannelConfig.build(): it is the single most game-shaped file ine2e/, and the one whose wrong contents wedge every channel of your fork part-filled with no error, so rewrite it for your own suffix layout;tests/e2e/match-cfg.test.tspins the literal cfg hex and asserts the blob accepts the production cfg and rejects the empty one, so its expected values are rewritten with the file;e2e/packed-determinism.tscarries its own cfg builder, field names and trace filenames; ande2e/selfplay.tsimports the template's bot. - A second game directory:
src/game/(types, and any mechanic module beside them) is separate fromsrc/lib/<game>/and is just as game-shaped. src/hooks/use-<game>-input.ts, andsrc/lib/arcade/presentation.ts's exported presentation constant.- The game-shaped unit tests under
tests/lib/,tests/hooks/andtests/components/: rung 1 of the ladder runs them, so they are not optional. - Stale
xbm/xayamanwords in comments (src/bootstrap.ts,blob/tests/fuel_probe.cpp) are not identity config, but on path B you rewrite those files anyway.
STORAGE_PREFIX (in src/app-identity.ts) is the identity edit that can silently break a live
game, not just yours: see §5, and set it from your GAME_KEY in the same edit.
Step 2: write the rules blob
C++ → blob/rules.wasm via blob/build-blob.sh, reproducible and DWARF-stripped. The full
toolchain: the zero-import freestanding reactor, the reproducible build, check-blob.sh --rebuild, the fuel probe, and how to author the golden traces your seat declaration rests on
: is this skill's WASM.md; its §A4 is the only place golden traces are documented, and there is no
procedure for them anywhere else.
The hard constraints:
-
Zero-import wasm. The register gate refuses any module with a non-zero import count (
ARCADE-ABI.md§7;wasm_judge.cpp'simports.size == 0). -
Twelve consensus exports, plus two structural ones.
arcade_alloc,arcade_free,arcade_parse_state,arcade_release,arcade_is_valid,arcade_whose_turn,arcade_turn_count,arcade_is_finished,arcade_winner,arcade_apply_move,arcade_initial_state,arcade_resolve_timeout: plus_initializeas a function andmemoryas an exported wasm memory (wasm_judge.cpp'skRequiredFuncs+ the memory check). Each must be present and of the right kind; a same-named global or table fails the gate. Extra exports are harmless.Do not write a single export from this list of names: get the signatures and the sentinel values first, from
WASM.md §A1or https://arcade.xaya.io/docs/rules-blob. The sentinels are the part that bites:arcade_winnerreturns-1undecided,-2draw, and a seat index otherwise. Guess it the obvious way round and you ship a blob that misjudges every drawn game: in a system with no self-serve update path, where the fix costs a new slug and a new gameType. -
Integer-only determinism. No floats, no randomness, no clock, no I/O. The same bytes run natively in your tests, in wasmtime inside the GSP referee, and in V8 in the browser; dispute resolution depends on byte-identical agreement between all three. (The determinism law: same ordered inputs must produce byte-identical state: is the foundation both game architectures rest on; load the building-persistent-games skill for the full section.)
-
No entropy source exists in the ABI: state bytes and move bytes, nothing else. If your game needs unpredictability, players supply it in move payloads under a commit-reveal discipline; arcade-xayaships is the worked example.
Your cfg bytes, and how to derive cfgSuffix. Do this once the rules exist, and write the answer
down in your own blob/MANIFEST.md:
-
arcade_initial_state(participants, cfg, cfgLen, out, cap)receives opaque bytes. The host never parses them (ARCADE-ABI.md); your blob defines and validates its own layout, and whatevercfgLenvalues you accept is your config contract.participantsis not always your seat minimum. A blob that hard-codes its seat count and rejectsparticipants == 1compiles, passes its own unit tests, clears the register gate - and then dies at setup in the template's e2e suite:buildWorldseeds every scenario withjudge.initialState(numPlayers, prodCfg(seed))- aChannelConfigbuilt by the SDK, never loose bytes, and throws when it comes back null, and scenario 7 builds its world withbuildWorld(1, 42)(buildWorldine2e/harness.ts, the one-seat call ine2e/suite.ts). Accept every count in[1 .. seats.max], answer the one-seat board as valid, and havearcade_whose_turnsay nobody has the turn.But the chain never asks your blob for the pre-start board, whatever else you may have read. A freshly created channel is stored with an empty state (
HandleCreateChannelcallsReinitialise (meta, ""),engine/gsp/logic.cpp); the referee short-circuits that empty state into a placeholder of its own without ever calling the judge (ARCADE-ABI.md, "The pre-start waiting state never reaches the blob"), and the template's browser-side rules do the same for a zero-length state. Every count the referee does pass toarcade_initial_stateis clamped into your registeredmin..maxbefore a channel can open at all (ParseCreateChannelMove, same file). The one-seat call is the harness's, not the platform's, which is exactly why answering it is cheap and skipping it costs you a red suite. -
The host builds
cfg = LE32(channel seed) ++ cfgSuffix: but only when the suffix is non-empty. With an empty suffix it skips the seed prefix entirely and hands you a 0-byte cfg (BuildCfginengine/gsp/logic.cpp; the Config section of the template'sblob/MANIFEST.md). There is no "seed only, no config" option: if you want per-channel variety you must register a non-empty suffix. If variety is all you want, one byte is enough: the suffix exists here only to make itself non-empty so the host prefixes the seed. Dungeon Channel registers exactly01, a single version byte that must never be empty (arcade-dungeonchannel/blob/MANIFEST.md), which is the cheapest way to buyLE32(channel seed). -
Write the layout down in your own
blob/MANIFEST.mdas a byte table. Two shipped examples bracket the space above the single version byte of point 2: xayaman's two-field suffix0200000001000000(arcade-xayaman/blob/MANIFEST.md), and - a better "copy this byte table" model at 2..4 seats - xayatrails' real multi-field one,0102090c068403= version 1, targetWins 2, maxRounds 9, and the rest (arcade-xayatrails/blob/MANIFEST.md). ThecfgSuffixyou submit is that layout minus the leading LE32 seed, hex-encoded, lowercase, even length, and at most 128 hex chars (CFG_SUFFIX_MAXinpreflight.ts). -
A game with no config takes the zero-length path, and it is a contract of its own. Submit
cfgSuffix: null: never""; pre-flight refuses an explicit empty string because a present-but-empty suffix sets the registry row to empty rather than leaving it unset (validateMetainpreflight.ts). With no suffix registered the host handsarcade_initial_statezero bytes and no seed:ChannelConfig.build(seed, '')mirrors that and drops the seed, so your blob must acceptcfgLen == 0and reject every other length. The inverse mistake is the silent one: register a suffix for a blob that wants none and every channel receivesLE32(seed) ++ suffix, the blob refuses it, and the channel wedges part-filled with no error. Pin both directions in a test that feeds the blob the registered shape and the other one. A worked zero-byte example:arcade-xayaships/blob/MANIFEST.md. -
Copy the value into the form (or into
meta) from yourMANIFEST.md: never type it from memory. Your golden traces must be replayed under the same bytes you submit, not under a private copy of them (WASM.md§A4).
Blob-side consensus safety: the untrusted-input discipline, blob edition. Everything reaching your blob is attacker-controlled: anyone can send any JSON as a move, and a channel's stored state is whatever the last accepted move produced.
- Parse and validate are two steps, and the split is not cosmetic.
arcade_parse_stateturns bytes into a handle;arcade_is_validanswers whether that handle is a state your rules would ever have produced. The host treatshandle == 0 || is_valid == 0as a plain reject (ARCADE-ABI.md). Never let a parse imply a validation. - Bound every count you read. A length, a seat index, a piece count or an array size decoded from input is a hostile number until you clamp it. A blob that indexes on an unbounded decoded count traps, and a trap is a reject, so it is not a fork, but it is a permanently dead game.
- You are metered and capped, and you cannot opt out. Each metered export call gets a fresh
budget of 500,000,000 fuel units, each instance is bounded at 64 MiB, and
arcade_apply_move/arcade_initial_state/arcade_resolve_timeoutwrite into an 16384-byte output buffer: a returned length outside0..16384, including the-2"too small" answer, is a terminal reject with no retry (docs/ARCADE-ABI.md§5, matchingkFuelCapandkStateOutputCapinengine/judge/wasm_judge.hpp, andkMemoryLimitBytesinengine/judge/wasm_judge.cpp). The judge is the authority for the 16384-byte cap; the template's test hostblob/tests/wasm_host.hppmirrors it so the determinism legs run under the same bound. Run the fuel probe (WASM.md). arcade_alloc(0)must return a valid non-zero pointer. The referee's judge callsarcade_alloc(len)with the input's real length, so an empty move/state/cfg callsarcade_alloc(0), and a0return there is a reject (docs/ARCADE-ABI.md§5, "Allocation sentinel"). The in-browser judge does the same thing:arcade_alloc(len)andarcade_free(ptr, len)with the true length, so both judges refuse an empty input identically. The on-chain registration gate settles it either way, by RUNNING your blob rather than reading its export table: the referee instantiates the module and callsarcade_alloc(0), and a blob that answers0- or traps - is refused registration outright instead of failing later on its first empty input. Pre-flight does not mirror that: it walks your wasm's export table in TypeScript and deliberately never instantiates untrusted bytes (WASM.md), so a clean pre-flight is not a clean gate: a playground's GSP applies the real one when your blob's registration reaches it (§3 step 4). A naivemalloc(0)is how you fail it;malloc(len ? len : 1)is what the template's ABI shim does (arcade-xayaman/rules/arcade_abi.cpp). Refusing zero-length inputs by length inside your rules is still worth doing, but it does not get you past the gate: answerarcade_alloc(0)with a real pointer either way.
Step 3: write the UI on @xayaarcade/sdk
Hard limitation, stated before you plan anything: the SDK is React/Next only today. It declares
react, react-dom, wagmi, @wagmi/core, viem, @tanstack/react-query and connectkit as
peer dependencies (arcade-platform/sdk/package.json), and it ships React components: the lobby, the
channel screens, the runtime-config gate. A non-React frontend has no supported path. If your
game must be canvas-first, that is fine: render your canvas inside a React board component. If it
must be another framework, there is no route today; do not start.
What you actually implement:
-
configureApp(...)+registerAdapter(...)at bootstrap, from one module whose import is the bootstrap (src/bootstrap.ts). It must run before any SDK screen renders: the template's file documents the four failures that follow if it runs late, and the nastiest one silently loses a returning player's session rather than crashing. A serverlayout.tsxmay import that module:configureAppandregisterAdaptercome from chunks without'use client'(sdk/src/bootstrap.tsnames the build rule). -
Three different ids, and they are not the same string.
GAME_KEYis the GSP registry key and the adapter registry key: it is what your lobby filters channels by. The move namespace is the arcade's sharedg/namespace, which many games ride at once.storagePrefixis yourlocalStoragenamespace on the games origin (§5). The template derives all three fromsrc/app-identity.tsso they cannot drift. The SDK lobby lists a channel only where the relay and the referee both name it as this game's, reading the type from the referee's own export, and leaves out a channel the referee does not list yet. A lobby of your own that callsfilterByGameTypepasses the referee's type for each channel id as its third argument (RefereeGameTypes,sdk/src/lib/channel/lobby-channels.ts). -
One
GameAdapterplus your board components. The adapter carries the rules binding, the channel wiring, the renderer and the input; the SDK gives you the channel protocol, session keys, the lobby, relay wiring, dispute handling and the shell bridge for free. -
A channel is judged by the rules blob it was opened with. The channel manager reads the channel's pinned rules-blob hash from the referee and hands it to
ensureLoaded(pin?)andmakeBoardRules(pin?). Pass it on toensureGameJudge(gameId, pin)andgetLoadedJudge(gameId, pin)(sdk/src/lib/wasm/blob-loader.ts), as the template's adapter does (arcade-xayaman/src/lib/games/xayaman-adapter.ts): judges are cached per hash, so a channel opened before a rules update keeps its own blob beside channels on the current one. An adapter that ignores the argument loads the registry's current blob, and the manager refuses a channel whose pin is not a loaded blob. -
The presentation declaration:
presentation?: GamePresentationon the adapter:aspectRatio,minViewport: {width,height},immersive?: 'never' | 'preferred',fullFrameOnTouch?: booleanandsquareBoard?: boolean(GamePresentationinsdk/src/lib/games/adapter.ts). The adapter is the source; thearcade:presentationmessage is only the transport. The seat range is not adapter state and is not in your code at all: it is a value you supply outside it (§3 step 0). -
Fit the frame you are given. The shell's play page is one viewport tall and never scrolls: the arcade bar takes a row, the iframe takes everything below it: the whole viewport once you go immersive: letterboxed to your
aspectRatioonly when the board has announced its presentation and the box is at leastminViewport(xaya-arcade/src/components/play/PlayShell.tsx). On a touch device a game declaringtouchControlsorfullFrameOnTouchgets the whole box. The frame is never resized to your content, so a document taller than it scrolls inside the iframe, and a player then scrolls between the board and the controls every turn. Your game owns the layout inside that rectangle: the board and every control an ordinary turn needs fit it in both axes; only an optional panel - history, help, a log - may scroll, on its own. Viewport units inside the frame measure the frame, which is why the template'ssrc/app/globals.csspinshtml, bodyto100dvhwithoverflow: hiddenandoverscroll-behavior: contain, and whysquareBoardsizes the board to the largest square that fits both axes: the template fits by construction, so keep those rules and size anything of your own from the box you measure. Check it on the real/play/<slug>page - in the shell, not the bare/g/<slug>/mount - at a desktop size (1366×768) and a phone size (390×844), in every screen the match passes through: a screenshot of the visible viewport shows the board and the turn's controls with nothing scrolled, and inside the child documentdocument.documentElement.scrollHeight <= clientHeightandscrollWidth <= clientWidth(a browser automation tool reaches the frame through its frame API; production code never can). -
A declared board has the notices above it, not over it. For a game that declares
presentation, with or withoutsquareBoard,ChannelGamelays the channel's notices (a dispute, a failed send, the wait on a silent opponent) out above the board, in the flow, so a banner never hides the top of a live board: the board gives up the room they take while they stand and gets it back when the column is empty, so measure the box with aResizeObserver, not once at mount, and include a screen with a notice shown in the layout check. A dropped peer is not one of the notices: the status line says it. WithoutsquareBoardthe renderer is laid out in the room the column leaves, as the bare root would lay it out, in a box with the classarcade-board-area(NOTICE_COLUMN_CSSinsdk/src/components/ChannelGame.tsx;aspectRatioinsdk/src/lib/games/adapter.ts). Only a game with nopresentationkeeps the notices over its board. -
squareBoard, if your board is one square surface. The SDK owns the square board frame: the largest square that fits both axes, centred, with the strips the status line, the on-screen controls and the controls hint sit in reserved on an upright phone. Declarepresentation.squareBoard: trueand delete your copy. Opt-in and inert otherwise: a game that sizes itself (two boards side by side, a fixed-ratio scene, an arena that fills the frame) is untouched, and so is every fine-pointer layout. The frame shrinks while a notice stands above it (previous item), so size the canvas from aResizeObserveron the surface. Migrating a renderer to it, in the order the mistakes are made: delete only your own frame and surface rules and return the board content - the wrapper element goes away and the SDK renders the frame in its place; putclass="arcade-board-surface"on the surface that used to carry your square sizing, because that is the one class the SDK's stylesheet acts on; and move anything that measured the old wrapper - aResizeObserver, agetBoundingClientRectfor canvas pixel sizing: onto the surface or the canvas, since an observer on a vanished node reports nothing rather than failing loudly. Every other class of your own stays exactly where it is; the SDK neither reads nor writes those. Two shipped games declare it, xayaman and xayatrails, both insrc/lib/arcade/presentation.ts. -
Keep the stage and the HUD inside the game's own root, never
position: fixed. Afixedelement is placed against the frame's viewport, not against the box the SDK lays the board out in, so a stage, canvas or HUD positionedfixedescapes that box and the notices the SDK lays above a declared board sit over it. Give the game a root that fills the box it is given (position: relative, the full width and height,overflow: hidden), position whatever covers the boardabsoluteinside it, and size the renderer from that container with aResizeObserver. Two things change with the switch. An absolutely positioned child of a grid is held to its grid area, so a panel in a grid HUD that should measure from the whole HUD takesgrid-area: auto. Andposition: fixedforms a stacking context even atz-index: auto, whichabsolutedoes not. A HUD that wasfixedheld its children'sz-indexamong its own contents; once it isabsolutewithz-index: auto(as a HUD that steps under the SDK's end-of-match recap is, the recap having noz-indexof its own), a child'sz-indexorders it against the page's layers, so it steps down with the HUD. The other way round, a root madefixed(to fill a practice page of its own, say) holds everyz-indexinside it to the root's own contents: leave the rootrelative, with noz-index, and let the page be as tall as the viewport instead. Dungeon Channel's stage and HUD are laid out this way (.dch-root,#map-panel.expandedand.dch-over #map-panel.expandedinarcade-dungeonchannel/src/game/style.css). -
The top bar's room is
--arcade-status-inset: read it, never hard-code the bar's height.ChannelGamedraws the status line and its buttons in a bar across the frame's top: one row on a wide frame, two on a narrow one, where the status line wraps below the buttons (useStatusInsetinsdk/src/components/ChannelGame.tsx). It measures the bar wherever there is aResizeObserverand sets--arcade-status-inseton its root to the room the bar takes, in px, and the notice column starts below that room, as the square frame does on an upright touch screen (BOARD_FRAME_CSS, same file). Insidearcade-board-areait is0pxwhile a notice stands, as the column has reserved the bar's room then (NOTICE_COLUMN_CSS). So a HUD of your own at the board's top keeps the bar's room clear withvar(--arcade-status-inset, <its own>), and one that keeps a gutter of its own while the room is0pxwritesmax(var(--arcade-status-inset, <its own>), <gutter>)(.dch-inset #hudinarcade-dungeonchannel/src/game/style.css). The fallback applies only before the first measurement and where there is noResizeObserver(aspectRatioinsdk/src/lib/games/adapter.ts), so a unit test sees only the fallback: jsdom has noResizeObserver, and the stand-in the template'svitest.setup.tsinstalls never calls back. Check the bar's room on the real page at the layout check's phone size. A hard-coded height fits one shape of the bar, whose height changes with the frame's width and with what the status line says. -
A screen of your own leaves the restore slot free while the chrome is hidden.
useArcadeBridge().requestChrome(true)asks the shell to hide its chrome and offers the shell's restore button the boxRESTORE_SLOTdescribes, at the frame's top right (sdk/src/lib/arcade-bridge/protocol.ts).ImmersiveOnChannel, which the template mounts for the whole app (arcade-xayaman/src/app/providers.tsx), calls it while a channel is live, which is whenChannelGame's top bar is on screen in a game that renders it. A shell that puts its button there says so onarcade:chrome-state, and while its latest one says souseArcadeBridge().restoreInSlotis true and the bar leaves that box free at the end of its button row. Standalone, and under a shell that does not say so,restoreInSlotstays false and the bar makes no room for the button (ArcadeBridgeValueinsdk/src/lib/arcade-bridge/ArcadeBridgeProvider.tsx). The offer goes with every hide request, whereverrequestChrome(true)is called, so any screen of the game's own that is up while the chrome is hidden, one that calls it or a board screen of its own whileImmersiveOnChannelhides the chrome, draws nothing in that box and takes no pointer events there: the shell's button is in it wheneverrestoreInSlotis true.RESTORE_SLOTis an export of the SDK's main entry, so such a screen lays itself out from it rather than copying its lengths. -
Playable by touch is a requirement, not a polish item. The arcade is browsed and played on phones, so every game must declare how it is played on one, and exactly two declarations count:
touchControlsfor a keyboard game andpresentation.fullFrameOnTouchfor a tap-native one, both below. They are not alternatives to pick by taste: a game that declares neither hands a phone a keyboard that is not there. The adapter declaration alone does not reach the arcade from a bundle. The games-host stores the answer on the manifest row as atouchboolean, and it takes that answer from the bundle itself: a file namedarcade-manifest.jsonat the archive root ofbundle.tar.gz, containing{"touch": true}(BUNDLE_MANIFESTandbundleDeclaresTouchinarcade-platform/games-host/src/touch.ts). The template ships it aspublic/arcade-manifest.json, which the static export copies to the bundle root, and its adapter test asserts the file'stouchequals what the adapter declares: keep the two in step. The bundle's own statement outranks every other source (games-host/src/registration.ts); thesrc/scan (checkoutDeclaresTouch) applies only when a game is registered from a checkout with--touch-from, which an upload never is. A bundle that ships no manifest leaves the row unanswered, and unanswered is treated as no. A row that does not say yes is labelled Desktop only (for now) on the game's card and page; a player on a coarse-pointer device cannot start or join a match in it from the lobby - the reason sits where the button would be - and a phone that reaches the game page some other way meets a warning line.GET /api/gameson the playground shows the storedtouchfor your slug the moment the row is registered. Nothing is refused over it. The shipped answers split both ways. Some declaretouchControls: a d-pad plus one action button (xayaman's adapter), a bare d-pad (xayatrails' adapter), and a d-pad plus three action buttons (arcade-dungeonchannel/src/lib/dungeon/adapter.ts, spec insrc/lib/dungeon/input.ts). One declaresfullFrameOnTouch: trueand mounts no pad at all: the tap-native case, whose board already is the input surface: xayaships (arcade-xayaships/src/lib/arcade/presentation.ts), which builds its own touch-only affordances behinduseCoarsePointer(). A game that draws its own controls must not also mount the generic pad: the pad lands in the same bottom gutter, on top of the controls. And a control that has to report state cannot be a key-synthesizing button, so it belongs to the game's own board. -
On-screen touch controls, if your game is keyboard-driven:
touchControls?: TouchControlsSpecon the adapter (sdk/src/lib/games/adapter.ts): an optional 4-waydpadand optional actionbuttons, where every value names theKeyboardEvent.keyit stands for. Adpadnames all four directions or is absent - a two-direction game declaresbuttonsinstead - and a button's key is held for as long as the button is pressed (keydownon press,keyupon release), the same level-sampled model a held keyboard key gives your hook. Declare it andChannelGamemounts the overlay by itself on coarse-pointer devices while input is live, synthesizing real window key events, so a keyboard game becomes phone-playable with no change to your input hook, its held-key model or its submit timing. The component is not exported; declaring the field is the entire opt-in. Absent - the default - means no on-screen controls, which is the right answer for a tap-native game whose board is already the input surface. That game still wants the phone's whole display, and asks for it withfullFrameOnTouchrather than by mounting a pad it has no use for.Touch examples: Xayatrails declares a d-pad (
arcade-xayatrails/src/lib/games/xayatrails-adapter.ts); Xayaman adds an action button (arcade-xayaman/src/lib/games/xayaman-adapter.ts); Dungeon Channel adds attack, torch and use (arcade-dungeonchannel/src/lib/dungeon/input.ts). Taurion Racer also declares a d-pad and buttons (arcade-taurion-racer/src/lib/games/racer-adapter.ts). A declaration alone does not prove the game usable on a phone: test the board and control layout together. Tap-native games can usefullFrameOnTouchwithout the generic pad. -
controlsHintis the line your win condition lives on, and it can be worded per device. The field isstring | { keyboard, touch }(sdk/src/lib/games/adapter.ts). A plain string renders on every device; a pair lets the SDK pick by the same coarse-pointer gate that mounts the pad, on the board and in the standalone menu. Reach for the pair when the keyboard wording would be a lie on a phone: "WASD move, Space bomb" printed directly above a d-pad, and say the same things about the game in the terms that device actually offers. Two rules hold either way. Keep it short: about 46 characters fit one line on the 320 px screen the arcade supports, and a second wrapped row eats into the board. And write it for both audiences even as a single string, because the hint renders on every device: it is where a game states its win condition. On touch it sits one step above the controls, running across the frame upright and staying in the pad's own gutter sideways. Code that reads the hint as a string must narrow it first:.length,.toContain, or passing it where astringis required does not compile on the union (TS2339), and a game's own adapter test is the usual reader. Narrow withtypeof hint === 'string', or resolve it withresolveControlsHint(hint, coarsePointer), which is exported for exactly that (sdk/src/lib/games/adapter.ts). Three shipped pairs to read:arcade-xayaman/src/lib/games/xayaman-adapter.ts,arcade-xayatrails/src/lib/games/xayatrails-adapter.ts, and Dungeon Channel's, which is the one to study: its keyboard wording spells out a whole control scheme and its touch wording names no key at all, while both say the same two things about how the match is won (arcade-dungeonchannel/src/lib/dungeon/input.ts). -
fullFrameOnTouch, if a phone should get your whole board: set it and the bridge skips thearcade:presentationpost on a coarse-pointer device, so the shell leaves the game the entire frame instead of letterboxing it toaspectRatio. A fine pointer is unaffected, and a game that declarestouchControlsalready gets the skip implicitly: its pad needs the game's own gutter to sit in, so it never sets both. On a portrait phone the letterbox costs a board game twice: xayaships was handed a 390x420 box out of a 390x844 screen, half the display dead, and shrank its own battle cells to 16.8 px: against 36.9 px measured with the full frame, or 32.6 px on touch where its fire bar holds a row open (arcade-xayaships/src/lib/arcade/presentation.ts). Full frame is only half the job, though: decide any small-screen layout change from the cell size you measure in the box you were actually given, never from a user-agent or device guess, so a narrow desktop window benefits and a tablet is not punished. -
useCoarsePointer()if you build your own touch affordances: the SDK exports the same coarse-pointer test its touch layer uses, so gate a tap-to-confirm bar or a fatter hit box on that instead of re-deriving it. The naive re-derivation is a real bug and not a style point: amatchMediaread in render scope gets memoized and frozen atfalseby the React Compiler your frontend builds with. The hook reads in an effect, so it answersfalseon the server and on the first client render and flips after mount. -
If you hash in the browser, pick your SHA-256 deliberately:
crypto.subtleneeds a secure context. It exists only under HTTPS or alocalhostorigin, and isundefinedwhen the page is reached over a plain-HTTP LAN IP, which is exactly how a phone gets tested. The failure does not look like a missing API: every hash rejects, and the symptom is a game that stops advancing. The SDK does not have this problem: it uses@noble/hashesprecisely so a bundle works over a LAN IP (sdk/src/lib/crypto/sha256.tsstates the reason), but your own code might. Three answers: import the SDK's asyncsha256/sha256Concatfrom@xayaarcade/sdk/coreand it works everywhere, as the shipped Xayaships and Dungeon Channel do (arcade-xayaships/src/lib/ships/ships-arcade-channel.ts,arcade-dungeonchannel/src/lib/dungeon/secret.ts); ship a synchronous implementation of your own when you need one inside a synchronous path such as alocalStoragevalidation; or - if you genuinely want Web Crypto - gate at boot with a message naming the fix, never let it fail per-frame. And do not confuse the two Web Crypto surfaces:crypto.getRandomValues- which is what a commitment salt actually needs - is available on plain HTTP over a LAN IP. Onlycrypto.subtleis secure-context-only. A game that reaches forsubtlewhen it only wanted randomness has taken the constraint for nothing. -
What the channel store publishes besides the board JSON, and why a renderer must read it. The board state your rules produce cannot express who is still in the match, so the SDK publishes four facts alongside it (the adapter contract in
sdk/src/lib/games/adapter.tslists them):matchOver(decided, or closed on chain by forfeit, timeout or abort),linkDown(this client has lost touch with the match - not the opponent),opponentDisconnected(a peer dropped off the relay; it is channel-wide, so above two seats it cannot say which), andejectedSeats: number[]- the seat indices the chain has removed by dispute timeout, read from the GSP's own export. That last one has no other source: the board JSON has no notion of an ejection and the GSP never edits the participant list, so a renderer working from the roster keeps drawing a player who was timed out of the match. Empty means nothing known (an older GSP omits the annotation), and the last known list stands once the channel leaves the open set: a close does not un-eject anybody. Read all four for anything drawn about the players rather than about the position. And the published state is cumulative: only the latest is ever delivered. A client rejoining a live match replays its whole backlog of proofs in one synchronous run and the intermediate states are coalesced away, because none of them could ever have been painted, so read what the current state says, and never derive anything from watching updates go by (a move counter incremented per update, an animation queued per delivery silently loses the burst). -
The renderer receives no submit handle, and the turn only as
_whoseTurn: bridge the rest from the sources that have them.GameRenderPropscarries onlylocalPlayerIndex(sdk/src/lib/games/adapter.ts); the livesubmitis handed to youruseInputhook alone, andChannelGamecalls the hook and the renderer separately. A keyboard game never notices, because its input hook owns the window listeners. A tap-native game, whose input originates inside the renderer's own component tree, bridges the two itself: the hook keeps thesubmitit was given in module scope and the board calls a plain function that forwards to it, dropping calls made while the hook'ssubmitis null: before the channel is ready, and while our answer to a dispute waits on the wallet, so the bridge tracks the current value, null included, rather than keeping the first non-null one (arcade-xayaships/src/hooks/use-ships-input.tsis the shipped shape). Likewise the store publishes nowhoseTurnof its own: the template's board reads aturnfield its packed state happens to carry (board-state-converter.ts). The board JSON the renderer receives carries the rules'whoseTurn()as_whoseTurn, beside_turnCountand_settlement(GameAdapterinsdk/src/lib/games/adapter.ts), so a renderer may read that instead of carrying a turn field. Forwinner()andisFinished(), and for a packed state that derives whose turn it is on an older SDK, carry the judge's own answers through yourBoardRules'toJson()so the renderer reads them from the board JSON, rather than recomputing the rules in TypeScript, which is the second rules implementation this whole design exists to prevent. -
Idle-tab hygiene: it is already done, so do not re-implement it. A tab parked on a lobby does essentially nothing between blocks, and nothing in a game changes for it. What a game must not assume: that a poll interval is running (a pushed tab runs no timer), or that a full state arrives every block. Read
heightandblockHashfromuseGspBlock()(sdk/src/hooks/use-gsp-polling.ts, re-exported from the main entry) in the component that displays them rather than at the top of a screen: that locality is the point, and hoisting it up redraws the whole screen on every block. To decode a relay document outside the hook, usewagerDocMatchesFor(doc, gameType)(sdk/src/lib/wager/wager-reads.ts, re-exported from the main entry).useChannelManagerreports every failed on-chain send assendErrors, dismissed withdismissSendError, paid or free: a dispute, a resolution or a win submission that fails lands in the in-match notice column with the reason instead of going to the console. Only an app that builds its own board screen on the hook reads them: a game that rendersChannelGame, which is every shipped game, gets the column with no code. Such an app also hands the hook'sseatDisputes, the disputes that have named this seat and how many of them were answered, toDisputeStatus, whose open-dispute panel counts them and, once three have been answered (REPEAT_DISPUTE_WARNINGinsdk/src/lib/channel/dispute-clock.ts), warns that each further answer costs a transaction. The count is this client's, counts each dispute and its answer once however a reorg replays them, and starts again with a new manager (ChannelManager.getSeatDisputes,sdk/src/lib/channel/channel-manager.ts). On that hook,readydoes not mean the relay join landed. The hook setsreadyonce its bootstrap is through and joins the relay behind the board (moves made before the join queue and go out with it; on-chain sends never need it), so a slow or refused join does not leave the board ignoring input. A dispute filed before the join accuses a peer we may simply not hear yet, so gate a File Dispute offer on the hook'srelayJoined;ChannelGamealready does, and shows "Connecting to channel..." until it is true. Same audience assendErrors: a game that rendersChannelGameneeds no edit. -
The stall clock:
stallGraceMsmay be a function, andthinkBudgetMscaps the whole match.GameAdapter.stallGraceMsis how long an opponent on the clock may go without moving beforeChannelGameoffers File Dispute, and it runs from the turn, not from the opponent's connection: a drop and rejoin does not restart it, and each move they make starts it again. It isnumber | ((board) => number): a number is one grace for every state; a function is asked with the board JSON of each state an opponent is on the clock in, so a game gives each phase its own grace, and a single number that covers the slowest turn lets an opponent answer every turn just inside it. The function must return a finite number of ms, 0 or more, for every board it can be asked with, the error board included: anything else (or a throw) gets the fallback (FALLBACK_STALL_GRACE_MS,sdk/src/components/ChannelGame.tsx), spends no think budget, and logs a console warning naming the board's keys. Code that readsadapter.stallGraceMsas a number (arithmetic, anumberparameter) does not compile: narrow withtypeof grace === 'number'first; an adapter test'sexpect(adapter.stallGraceMs).toBe(n)still does. The optionalthinkBudgetMsis the time an opponent seat may spend on the clock over the whole match, which no move restarts: a seat with less left than the grace gets what is left, but never under the spent-budget grace a turn (or the stall grace, if shorter). That isSPENT_BUDGET_GRACE_MS(same file), or the adapter's optionalspentBudgetGraceMsin its place: a real-time game whose turns take a moment sets a few seconds (the template's adapter does), so a seat that has held the table past its budget is offered for dispute at once. Set the budget only where every turn can be answered in that time once it is spent. The count is this client's and starts again with the page. The seat on the clock comes from the rules'whoseTurn(), so a game needs noturnkey in its board JSON for any of this. A game in which a seat can have won while the seat on the clock still owes moves (a loser's reveals after a kill, say) declares the optionaldecidedOffChain(board, seat), true whenseathas won onboard. It is asked of this client's own seat while an opponent is on the clock, and where that opponent goes the state's stall grace without a move, this client files the dispute itself, once per state, instead of only offering File Dispute. It files only while the relay join holds and no dispute stands, never for a state whosestallGraceMsanswer would get the fallback, and a throw counts as false; a wallet that asks before each transaction asks for this one too. Answer false for a seat that has not won, so a loser's client never files against a winner that owes moves of its own (decidedStallGraceinsdk/src/hooks/use-channel-manager.ts). YouruseInputhook must never gate a submission on presence (linkDown,opponentDisconnected): seePITFALLS.mdrow 85. -
Saved moves and the saved record.
useChannelManagersaves each move of ours before it is held or sent, and drops the move with a notice on the board when it cannot: the write failed (the origin's storage quota is the usual cause), or this seat has already signed at that turn count or past it, or has already signed a move from the state this one starts from or a later one, in another window or before a reload. That is what stops a reload from signing a second, different state at a count already signed, or a second answer to one position, whatever the two answers weigh. A game that implementsSignedStateStoreitself (the SDK's hook is the only known one) gets that starting turn count assaveSigned's fourth argument and must refuse the same way (sdk/src/lib/channel/channel-manager.ts). The record is one per channel, shared by every tab of the origin. It is removed once the channel's close is final, not when it is mined, because a reorg can undo a close:ChannelLobbysweeps once a visit, and a close is final on a read at leastCLOSE_FINAL_BLOCKSpast the first read that found its channel gone, while that first read's block, and an earlier block in which a direct read of the referee found the channel open, are both still the chain's as the chain's RPC reads them: the channel closed between the two, so its close is that deep. The board's own reads of its channel, a sweep's reads andisChannelCloseFinalkeep the first and the last block they found it open in, beside the record; an export listing keeps none. Only a reorg deeper thanCLOSE_FINAL_BLOCKS, which the GSP itself does not follow without a resync, then brings the channel back. A channel with nothing on the chain to date its close is kept for good, with what the game keeps for it: one no direct read found open before it was found gone (a create still pending, or a channel that closed before SDK 0.22.4), or one whose open blocks have all left the chain (markChannelGoneinsdk/src/lib/channel/channel-record.ts). A game with its own lobby callspruneClosedChannelRecords(openIds); one that rendersChannelLobbydoes nothing. What a game keeps in storage for each channel (a secret, a salt) has to last as long, since a reorged close puts the seat back in the match. The adapter's optionalchannelStorage: { channels(), forget(id) }frees it then:channels()lists the ids as 64 hex digits with no0x(the formchannelIdHexgives; any other entry is skipped), the sweep reads the referee for them as well, at mostPRUNE_READS_PER_VISITchannels a visit with the SDK's own records first, andforget(id)is called with the lowercase id once a close is final, also for a channel the game did not list. A listed channel that no read found open is never forgotten, so its storage stays (GameAdapter.channelStorage,sdk/src/lib/games/adapter.ts). For an id kept anywhere else,isChannelCloseFinal(id), given the id as 64 lowercase hex digits (anything else answers false), reads the referee the same way and answers true only for a channel a direct read found open before it closed, its own reads included, so ask while the channel is open and again on later visits until it is true, andonChannelClosedFinal(listener)is told each id, once, in the tab whose read found its close final, after its records are freed. A game's ownlocalStoragewrites are what a full origin breaks first, so keep them small. A game that needs room for a match can turn a table down before anything is signed: the SDK lobby anduseArcadeWagerask the adapter's optionalbeforeTable()before they open or join a table, free or staked, ahead of the create or join transaction (a staked table's WCHI approval comes before it), and show the message it resolves in place of the action;nullgoes ahead (beforeTableinarcade-dungeonchannel/src/lib/dungeon/adapter.tstests a write). -
An unsaved session key blocks create and join.
getSessionKeydoes not throw where storage refuses the key (a sandboxed or partitioned frame, a full quota): it holds one key per wallet for the life of the page and saves it once a write succeeds. A seat opened with a key only the page holds could not be played (its moves cannot be saved, and a reload loses the key), so whilesessionKeySaved(wallet)is false the SDK lobby's create and join, free and paid, refuse with the noticeSESSION_KEY_NOT_SAVED_COPYand send nothing (sdk/src/lib/crypto/session-key.ts,sdk/src/components/ChannelLobby.tsx).useArcadeWager'screate()andjoin()refuse the same way and put the notice onerrors(sdk/src/hooks/use-arcade-wager.ts).sessionKeySavedandSESSION_KEY_NOT_SAVED_COPYare exports of the main entry. A game's own free create or join (one that callscreateChannelOnChainorjoinChannelOnChainitself, which do not check), or a paid path that does not go throughuseArcadeWager, must checksessionKeySaved(wallet)itself, before it sends anything, and test it: a test that mocks storage to throw and expects a create or join to go through fails. -
Exports the SDK does not have. A game's
src, tests and e2e, selfplay or on-chain drivers fail to typecheck or run if they use one, sogit grepfor each name. The full list is in the SDK'sCHANGELOG.md; the ones a builder could have reached for, with what replaces them:SessionSigner(new PrivateKeySigner(privateKeyToAccount(key))signs the same),EthWalletSigner(gone from.and./e2e),disputeChannelOnChain(a channel's disputes go throughMoveSender.sendDispute),declareLossOnChain(forfeitChannelOnChainsends it),ArcadeWagerState.needsApproval(callneedsApprovalFor(funds, stake)) andcanEvictSeat(the same asenabled), theGspClientcallsgetStatus,waitForChangeandcancelWait,ChannelManager.getStateVersion,useChannelManager'schannelManagerandchannel,useArcadeBridge().chromeHidden(nothing replaces it;restoreInSlotsays only whether the shell's button is in the slot, §3 step 3), theWagerErrorItemtype (useArcadeWagerState['errors'][number]) anddwarn(if (debugEnabled()) console.warn(...)). -
Wallets: injected only.
wagmiConfigoffers the injected wallet (and the dev wallet on a fork rig), with no Coinbase or WalletConnect connector, and reads noNEXT_PUBLIC_WC_PROJECT_ID. Do not set that variable in a Dockerfile, compose file or build script, and do not document either connector. -
An abandoned
initialize()and a late preload failure do not reach the board. An attempt whose hook unmounted returnsnulland acts on nothing, and a game-module preload that fails after the hook was hidden or unmounted, or that a later load supersedes, is not reported asloadError, soChannelGame's load-failure overlay does not cover a board that loaded. A test that drivesuseChannelManagerthrough unmount and remount, or a failing preload, sees this. -
Details a snapshot, pixel or call-count test can pin.
MatchStartingOverlay's detail lines use the--arcade-ink-2token,WalletLogin's fork-rig test-wallet buttons use the board HUD's button style, andapproveWchireads the contract'swchi()before the chain switch. -
Relay reconnection and the multi-tab cases are already handled in the SDK. The relay keeps one connection per address per channel and closes the socket a re-join supersedes: a second window of the same game, or an old socket left half-open by a network drop or a sleeping laptop, with its own close code rather than 1000 (
CLOSE_SUPERSEDED,xaya-relay/src/channel-router.ts). The client reconnects on every close it did not initiate, whatever the code: the deliberate disconnect detaches its handler first, so every close that reaches the handler is one the client did not ask for. On top of that: reconnects back off to a ten-second ceiling and dial immediately when the browser reports its network back (sdk/src/lib/relay/ws-broadcast.ts); a match open in a second tab is named as such, with one Play here button that takes the seat back, instead of the two tabs evicting each other; a join the relay refuses as a replay - a reload landing in the same wall-clock second sends a byte-identical signed join - is re-signed and replayed once after the clock ticks over; and a seat whose key lives in another browser profile is recognised before anything is attempted, with a banner rather than a permanent "Connecting to channel…". That last one is worth knowing as a fact about the protocol, not just a fix: the key that signs for a channel seat is generated in one browser profile's storage and derived from nothing, not the wallet, not the name, so a seat cannot be continued on a second device or after site data is cleared. Underneath all of it the relay pings every connection on a cadence its operator sets and terminates one that does not answer (HEARTBEAT_MS,xaya-relay/src/limits.ts), so a silently dead peer becomes apeer_leftwithin a couple of intervals - which is what starts the game's own disconnect grace - instead of the match sitting there looking live with nobody behind it. Nothing in a game changes for any of this, but a bundle only gets it by re-vendoring the SDK and rebuilding. -
The in-browser judge. The same
rules.wasmruns in V8 in the player's browser (sdk/src/lib/wasm/packed-judge.ts) so the client can validate a proof locally without a round trip. This is why native == wasmtime == V8 is a gate and not a nicety. -
The SDK's CSS reaches your build only because of one line: keep it. Tailwind v4's automatic source detection ignores
node_modules, and the SDK's shared screens ship their classes insidedist/. Sosrc/app/globals.csscarries@source "../../node_modules/@xayaarcade/sdk/dist", and if you delete, move or mistype it the build still succeeds and the lobby, the login and the HUD render completely unstyled: a failure no unit test can see, because it exists only in the emitted stylesheet. Runnpm run verify:css(scripts/check-sdk-css.mjs), which greps the built CSS for a sentinel class used by an SDK component and by nothing in your repo. Keep the companion@source not "../../scripts"line too: without it Tailwind scans the guard's own source and emits the sentinel from there, so the guard passes even when the real@sourceline is gone; removing that line silently disarms the check. -
Write NO wallet code. An embedded game holds no wallet. The shell signs everything over the postMessage bridge (
sendMove(moveJson, signerName) → txHash). The second argument is the channel participant you hold, and it is REQUIRED: it binds the move so the shell refuses to sign under a different arcade identity.BrokeredWritersupplies it from the move's ownargs[1], so you normally never touch it. -
Smooth Play: the shell may put a move on chain with no wallet popup, and your game must not know or care. A player can switch on a session key in the arcade: a burner derived in the shell's own origin from one EIP-712 signature by their wallet, funded with a little POL, and granted a scoped on-chain delegation, and the shell then signs eligible moves with it locally instead of raising a prompt (
xaya-arcade/src/lib/smooth-play/{session-key,routing,send}.ts, used atsrc/components/play/GameFrame.tsx). Nothing about the bridge changes: you still postarcade:move-requestand still get onearcade:move-resultcarrying a tx hash or an error, and the shell falls back to the wallet path whenever the delegated one does not apply. Three rules follow, and they are the whole of a game's obligation.The key is never handed to the frame. The shell keeps its session key in its own storage (
xaya-arcade/src/lib/smooth-play/session-key.ts). The shipped planes' separate games origin prevents the frame from reading that storage. Enabling the same-origin override removes this boundary (§5). Games must use the bridge and must not request or search for shell keys. Do not assume a popup, in either direction. A move may now come back confirmed in far less time than a human takes, so anything gated on "the player is in a wallet prompt right now" is wrong; equally, Smooth Play is per player and per session and its grant lapses, so a popup can return at any moment mid-match. Treat amove-resultas the only signal, exactly as before. Emit the ordinary envelope and it stays eligible. The delegated path is granted at the move's own nesting - thegnamespace plus one game id - so the shell routes a move only when it parses as exactly that: one top-levelg, exactly one id under it, an object payload, built withJSON.stringify.BrokeredWriteralready produces that shape, and anything else (a sibling key smuggled besideg, a non-object payload) is not a failure: it simply takes the wallet path and costs the player a prompt. -
Animate from a
requestAnimationFrameloop reading refs or stores, never from render scope. Game frontends build withreactCompiler: true, and an impure read in render scope gets argument-keyed-memoized and silently freezes. Verify against the built bundle, not vitest: the bug is invisible to jsdom. (Full row in this skill'sPITFALLS.md.) -
Never construct a root-absolute URL at runtime, and never write one as a literal. See §5.
Step 3a: how a player actually arrives, and the one signature that gets them there
This is the default route into a game on the arcade, and a builder who does not know it will
misread their own lobby. A player does not normally open your game and press Create a game. They
agree a match with someone in the arcade's cross-game lobby: one lobby spanning every game,
free until both sides accept, and land in your bundle with the agreement already made. Everything
below is recorded release by release in arcade-platform/sdk/CHANGELOG.md, which is the authority.
- The message. After
arcade:caps, the shell may send{ type:'arcade:matchmake', role, seats, tier, peerName, ts }.roleis'create'or'join', which half of the agreement this player took;tieris'free'or the agreed stake in WCHI base units;seatsis 2..8;peerNameis the opponent's namespace-stripped Xaya name. The parser rejects a bad role, a seat count outside 2..8, and a'0'tier: free is spelledfree, never a zero stake. It surfaces asArcadeBridgeValue.matchmake: the agreement for this session, ornullwhen the player did not come through the lobby (and alwaysnullstandalone).routeShellMessagetakes an optionalonMatchmake; absent means drop in silence. It also drops a message whosetsis more than 30 minutes old or more than 5 minutes ahead of the game's clock (MATCHMAKE_MAX_AGE_MS,MATCHMAKE_MAX_AHEAD_MSinsdk/src/lib/arcade-bridge/route-shell-message.ts), so an old link opened from history shows the ordinary lobby: a test fixture needs a currentts, and one with a fixed old value is dropped. Additive throughout - an older game drops the message, and a game on this SDK against a shell that never sends one renders exactly the ordinary lobby. - A staked agreement acts on its own; a free one waits for one press. A staked step is put in
front of the wallet as soon as the agreement can be acted on, and
the wallet prompt is the consent: a paid creator is asked to approve WCHI first when their
allowance is short, then to stake, and a joiner's join goes out the moment the agreed peer's table
appears. A free step may go out with no prompt at all (the shell signs an embedded game's
moves), and the agreement arrives in a link anybody can craft, so
ChannelLobbysends the free create or join only after one press of "Start the match" on the banner ("Matched with p/<peer> - start to open the game.", or "p/<peer>'s game is open - start to join it."). The press is per agreement and per page: the same link opened again asks again, and a step an earlier page already sent offers "Try again" rather than a start. Automation that drives an agreed free match (a browser test, a selfplay or e2e driver, a headless client waiting at an open table): a driver that opens the agreement must press "Start the match" and hand off atsinside the limits of item 1; a client waiting at its own table cannot press anything and waits for the peer's page to, so its wait timeout must allow for a person. - At most one automatic send per agreement per step, latched in
localStorage. The agreement travels in the page's own URL, which makes that URL a replayable token for an automatic transaction, so neither a reload, nor reopening the link from history, nor middle-clicking it into a second tab may produce a second prompt, which would mean a second table or a second escrowed stake. A prompt the player rejects, or a send that fails, is never answered with another prompt: the banner says nothing was sent and hands overTry again. - A played agreement never arms again. It is CONSUMED the moment its own transaction confirms, keyed by when it was struck and who with, page reloads included.
- A spent agreement never talks a player out of a game they are still in. Match played: find your next game in the Lobby. appears only when nothing of theirs is standing; while their table is open, their stake is escrowed, or their match is live, the banner says that instead, because leaving is the losing move in every one of those states, and at a stake it loses the stake to the timeout as well.
- Embedded, your manual create card is demoted. Inside the shell it folds behind Advanced: open a table on-chain now, collapsed on every mount and never remembered: except a card the banner has armed (a paid creator told to stake below), which renders unfolded with no toggle. The fold is presentation only: same card, same handlers, and opening or closing it signs nothing. Standalone builds are unchanged: no shell lobby to route through, so the create card stays primary and no disclosure element exists in the tree at all. A live agreement goes further and does not render the card at all unless the banner armed it (item 8); the fold is what an embedded lobby with no agreement in hand shows.
- The 90-second staleness line on a joiner's wait is a line of text and nothing more:
nothing is cancelled, nothing is unarmed, and the clock restarts if the wait does. What does
end a free agreement's wait is the chain's own record of the exact table being watched: once an
export at least as new as the table shows it filled without you, or shows the peer cancelled it,
the wait is replaced by a line saying which of the two happened and the agreement is retired with
it, so a later table of that peer's is not joined on your behalf. The "at least as new" guard is
the point of the mechanism: a relay-cached state older than the height the table was seen at is
missing that table because it did not exist yet, and reading that as a cancellation would retire
a live agreement and send the player away from a table that is still open for them
(
watchedTableRefinsdk/src/components/ChannelLobby.tsx). A staked match opens its table on the referee only once it fills, so a staked agreement has no table to watch: while the agreed match is open the lobby notes the peer's staked tables of the agreed shape, and once the match has left the wager contract's open set, a table of that shape it had not noted, which does not seat you, ends the wait as filled without you. A staked match cancelled or expired is not told apart from a fill the export has not shown yet, so its wait line stands (watchedMatchRef, same file). Neither watch judges anything while a game of yours stands, a stake escrowed in the agreed match included, or once the agreed match has seated you. Nothing on chain reserves the peer a seat at the host's table either, so when the agreed match fills without the peer, the board tells the half that agreed to host, with a toast and Not the agreed p/<peer> on the status line (missingAgreedPeerinsdk/src/components/ChannelGame.tsx; the agreed table is latched bylatchAgreementTable,sdk/src/lib/channel/agreement-latch.ts). - Embedded, a live agreement gets the whole screen to itself. While the agreement is
live, only the agreed match is drawn (with the cancel for a table of your own moved onto that
card, so a player who changes their mind can always close a table they opened), and the ordinary
lobby comes back the moment the agreement ends, cannot be honoured, or is spent with nothing of
yours standing (
focusedOnAgreement,sdk/src/components/ChannelLobby.tsx). The lobby shows no raw channel ids. Standalone is untouched: there is no arcade lobby to have come from, so that is the only lobby there is. Nothing in your game selects this; it follows from the handoff. - What you must do about all of this: nothing in the game, but rebuild; automation, see item 2. There is no protocol change and no shell release involved, but every game bundle carries its own copy of the lobby, so re-vendor the SDK and rebuild to pick it up. A bundle built on an older SDK keeps that SDK's lobby flow while the shell around it has moved on.
- One API note:
approve(),create()andjoin()onuseArcadeWagerresolve the tx hash they submitted, ornull(Promise<string | null>,sdk/src/hooks/use-arcade-wager.ts).
Step 4: test locally, with optional playground integration
There is no chain in the template. Anything that needs a channel to be opened, adjudicated, relayed or closed needs an arcade running. Local rules and peer tests work without it. For a real match before you submit, attach your build to the public playground (below). The operator runs integration tests during review.
Offline: no chain, no relay, no funds, no network:
npm test unit + repo suites
bash blob/tests/run-tests.sh the C++ golden battery against the real blob (docker)
npm run e2e the channel scenarios (the SCENARIOS array in e2e/suite.ts)
: no chain, no relay, no funds
npm run e2e:determinism the real blob in V8, replayed against the goldens
npm run selfplay the scripted move driver
bash blob/check-blob.sh structural gate on the blob (zero imports, export set,
no .debug_*, toolchain fingerprint, sha256 vs sidecar);
--rebuild also reproduces the bytes in the pinned container
npm run verify:css the SDK's styles actually reached the built stylesheet:
the one failure below that no unit test can see; needs a
prior build (npm run build, or the --bundle export)
npm run dev single-client UI work ONLY
That is the whole offline ladder, and every rung of it is provable on your own machine. One more
gate needs a server: npm run e2e:relay (e2e/relay-match.ts) plays the same match over a real
xaya-relay instead of the in-memory bus. It needs a running relay but no chain and no funds, and it
fails rather than skips when no relay answers; the template's e2e/README.md documents it.
There is no command that checks your four registration values against your blob: no such tool exists, and the reconciliation in step 5 is something you do by reading and comparing, not by running.
npm run dev is not npm run dev:playground. Bare
dev sets no environment at all: the browser's wagmi transports fall through to real Polygon
(getChainRpcUrls, sdk/src/lib/config/service-urls.ts) and the dev wallet is off
(sdk/src/lib/chain/dev-wallet.ts). It renders the UI and nothing else: bridge inert,
no chain reachable, no dev wallet, no channel. Run it expecting a playground and you get a
wallet-connect prompt with no explanation.
Optional: against a playground:
PLAYGROUND_ORIGIN=https://test-arcade.xaya.io npm run dev:playground
The template's package.json requires PLAYGROUND_ORIGIN and derives the relay,
chain and GSP proxy destinations from it: https://test-arcade.xaya.io is the public
playground; any other origin works too. It sets the relative chain RPC and enables dev
wallets for that invocation. Keep these settings out of .env.local: Next also loads
that file for production builds. A match needs your rules on that playground first:
attach them (below). Only iterate the board here: the
picker's four wallets are the SDK's fixed addresses, which a playground neither funds
nor names, so a match is played on the playground's own /play/<slug>.
The recognition test, because it is how you know the playground settings took: on load you
should see a dev-wallet picker, not a wallet-connect prompt. A ConnectKit / MetaMask prompt
means the environment did not reach the browser; stop and fix that before debugging anything else.
What that picker is not, on a playground, is a funded seat. The roster behind it is the SDK's
fixed four addresses (ALL_DEV_WALLETS in sdk/src/lib/chain/dev-wallet.ts); the playground funds
and names four wallets of its own, which are the shell's. So the dev server is where you iterate the
board against the playground's live chain, relay and referee, and a match is played through the
playground's shell at /play/<slug>, where identity arrives over the bridge (below).
Then build the bundle for the plane. NEXT_PUBLIC_GAME_ID is the arcade's shared g/ move
namespace: whatever that arcade serves as gameId in <basePath>/arcade-config.json
(games-host/src/handler.ts, the /arcade-config.json branch; cited by branch rather than by line,
because that file grows above it). It is per plane, so read it rather than remembering it: a
curl of that JSON is the answer for whichever plane you are building for. For that build:
NEXT_PUBLIC_GAME_ID=<gameId from that plane's arcade-config.json> bash scripts/build-export.sh --bundle
That build prints warnings on a correct run: do not chase them. The loudest is the
frame-ancestors one: with FRAME_ANCESTORS unset the export emits frame-ancestors 'none', a
build nothing can embed, and scripts/build-export.sh says so rather than letting it pass
unremarked. For an Arcade submission that is expected and right: the platform bakes and serves the
headers itself at registration, so FRAME_ANCESTORS is for a build you host yourself. Setting it to
guess the arcade's origin is worse than leaving it unset.
If you do host an export yourself, read buildSecurityHeaders before you deploy it. It is the
SDK export the template's src/middleware.ts calls, and the one scripts/build-export.sh mirrors
into the static header files a fileserver reads, so its contract is your deployed CSP either way.
The signature is buildSecurityHeaders(frameAncestors?, { connectOrigins? }),
and two properties decide whether a self-hosted build actually works. It always emits
frame-ancestors - 'none' when you pass nothing, beside X-Frame-Options: DENY - so "no header"
is never the outcome, only a value that refuses every embedder. And its connect-src names
concrete origins only: there is no bare wss:/ws: scheme in it. A relay, GSP or chain RPC on
a different origin from the page must therefore be named: pass it in connectOrigins, or set
NEXT_PUBLIC_RELAY_URL / NEXT_PUBLIC_GSP_URL / NEXT_PUBLIC_POLYGON_RPC at build time and its
origin is taken from there. Miss that and the build loads, renders, and then cannot open its relay
socket: a failure that looks exactly like a dead relay. The CSP names no WalletConnect host, since the
wallet list is the injected wallet only. script-src allows WebAssembly compilation only
('wasm-unsafe-eval'): a production bundle that calls eval or new Function is refused, while
next dev keeps 'unsafe-eval' for React's development build.
On the public playground, attach it. Open https://test-arcade.xaya.io/attach and enter the slug,
your game type (your code's GAME_KEY), the seat range and cfg suffix from blob/MANIFEST.md and your
repository's https URL, then upload blob/rules.wasm, dist/bundle.tar.gz and their .sha256
sidecars, the bundle built with the playground's namespace as above. The upload runs the submission
pre-flight (arcade-platform/submissions/src/preflight.ts), the bake and a real registration, and goes
live with no review; the same page lists every attached game. It skips the registrar's opening-state
check (assertUsableOpenings in arcade-platform/scripts/register-rules.ts), so a wrong seat range or
cfg suffix shows up as a channel that fills and never starts. Attached games are free-play only. To
ship a fixed build, attach again from the same browser: the page keeps the resubmit token that proves
the slug and game type are yours. The chain is wiped without warning and nothing there reserves a name
on the arcade: apart from the pre-flight it runs, the not-proven list below applies to it as well.
Where the pipeline runs. To run the services yourself instead of attaching, see
arcade-platform/docs/RUN-FROM-SCRATCH.md.
A game's content row can carry techDemo: true for a catalogue Tech Demo badge; on the arcade that
row is the operator's, so ask for it in your submission. It supplements the status label and does
not enable wagering or change the registered rules.
- Play it at
https://<your playground>/play/<slug>: a game with a games-host row and a content row gets the same/play/<slug>page production serves, with your bundle framed inside the real shell, and a row in the lobby's picker. That page is what makes the embed evidence about your bytes: the iframe, the postMessage bridge, shell-brokered signing, the shell's letterboxing and its CSP are all exercised for your game here, not just beside it. The bare mount is still served at<this plane's games origin>/g/<slug>/: the path-mounted URL the shell's iframe itself loads, and that is the one to open when you want your game top-level with no shell around it. Read that origin off the/play/<slug>iframe'ssrcrather than remembering it: it is a per-plane operator setting and may carry your slug in the hostname; on the shipped planes it differs from the shell origin (§5). Because/play/<slug>is embedded, your own create card is folded behind Advanced: open a table on-chain now there (step 3a point 6), and on this plane that fold is how you open a table at all, because the shell's cross-game lobby never offers you one (item 4 below). Opening a second window from the same browser profile does not give you a second player, at either URL: the dev-wallet slot is per-origin, so both windows are the same account (next bullet). - Play it at every seat count you intend to declare, from N isolated browser profiles on N
different dev-wallet slots. "Open two tabs" does not generalise and is wrong even at N=2: the
dev-wallet slot is a single per-origin
localStoragekey, so two tabs of one profile are the same player. N seats need N isolated storage contexts (N browser profiles, or N-1 plus one private window); in each one, explicitly select a different dev-wallet slot and reload - the selection is persisted per origin and only takes effect on the next page load (arcade-platform/sdk/src/lib/chain/dev-wallet.ts-selectedSlot/selectedDevWallet/selectDevWallet, keyed offstorageKey(), which is yourstoragePrefix). Each slot owns its ownp/names, so every seat is a distinct named identity. Every visitor starts on slot 1 selectedSlot()returns'1'whenever nothing is stored - so if two people are testing at once and neither switches, they are the same account racing each other's nonces.- The four test wallets are the plane's own, minted at bring-up. If a transaction inexplicably fails, another window of yours is probably on the same slot: switch slots and reload.
Fork resets. The public playground's operator resets its fork from time to time, which destroys test chain state, referee data and runtime registrations. Attach again afterwards and reload browser tabs.
Fork growth. Anvil retains mined history in memory; connector/referee data, logs and build
caches also grow, so a fork is reset when its test state is no longer needed. Do not restart the
in-memory basechain alone while retaining databases that indexed its old history. For raw fork-EVM
mechanics and reorg testing, load the building-persistent-games skill.
What a playground does NOT prove
This is the single not-proven list. There is no second one.
- Not Polygon. No real gas, no real WCHI, no real name prices, no mainnet congestion, no reorg
behaviour. Above all: a name or
gameTypethat is free on your fork may be taken forever on mainnet. Nothing answers that in advance: the operator's pre-flight over your submission is the first and only check (step 0). - Not uniqueness. The playground's registry holds the games attached to it and the
first-party ones, so a name is never tested against the real registry there: read
RESERVED_SLUGSinpreflight.tsyourself (step 0). - Not the arcade's own shelf. Your game is in the playground's shell: its lobby, its own
/play/<slug>, your bundle in the frame, so the embed IS proven for your bytes here (§3 step 4). What it is not is the real arcade's catalogue: nothing on the playground is listed, ranked, reserved or discoverable on arcade.xaya.io. The mechanism is worth knowing, because it is the same one on every plane: the shell's catalogue, its/play/<slug>page and the lobby's per-game entry all hang off a content row: on the arcade the operator's, on the playground one its attach writes from your upload, and a plane with no row for your slug gives your game no/playpage, no tile and no lobby entry, only the bare/g/<slug>/mount. - Not the agreed-match handoff: for anybody, on a playground plane. Step 3a calls it the
default route into a game on the arcade, and it is the one thing on this list you cannot even
watch a first-party game do here. Every action in the shell's cross-game lobby: agreeing a match,
creating from it, joining it: is gated on a connected chat client (the
readygate inxaya-arcade/src/components/lobby/LobbyClient.tsx, "an unreachable lobby must also be an unclickable one"), and the playground shell is built with no homeserver. So no match is ever agreed here, noarcade:matchmakeis ever delivered to any bundle, and the banner that acts on one never arms. Your game is an offerable row in that picker: the rule is "the content model calls it arcade-playable and this plane serves a bundle for its slug", and it deliberately never consults a wire type (xaya-arcade/src/lib/registry/plane-games.ts,playableOnPlane), but the picker itself never renders here, so you cannot reach it. The one lobby surface that works without chat, the on-chain "Waiting to play" list, is exactly the one that needs a wire type, and a row the shell reads at runtime never carries one: the loader strips it so such a row can never claim an existing game's channels (xaya-arcade/src/lib/registry/load-games.ts,open-channels.ts). So a channel you open in your game is not listed there either: you reach your game by its URL. YouronMatchmakehandling is first exercised on the real arcade. - Not a real wallet's latency or rejection paths. Dev-wallet signing is instant and never rejected; the move broker waits forever by design. A game that renders a dead UI during a slow human approval looks fine here. The real ConnectKit and injected-wallet (MetaMask) flows are not exercised either: the shell around your game is real on this plane, but the wallets behind it are dev slots, so a signing-UX bug that only shows with a browser extension is not caught here.
- Not wagering with real stakes. A game attached to the public playground is free-play only.
Whether a game takes real stakes on the arcade is an
operator switch, not something this run decides. See this skill's
WAGERING.md. - Not the games-host or GSP version the arcade runs. The playground runs the platform
versions its operator pinned and the public arcade runs its own. The
arcade:*wire is frozen and additive-only so this is bounded, but a platform-side behaviour change reaches each plane when its operator updates it. - Not a security review. The shipped planes isolate the shell from the games origin (§5), but a local playground run does not vet your code, assets or rules. Listing still requires repository review. Isolation does not prove a game safe.
- Not N>2 seats, unless you played them, and N>2 IS playable, so there is no excuse. All the slots can complete a match: off-chain proofs are signed by a browser-generated session key, and on-chain moves are auto-impersonated, so a slot with no local private key is still a full player. A 2..4 game that only played the 2-seat path has proven the 2-seat path. Separately and always: playing here is not evidence for the seat declaration: that comes from the replay leg on your own machine, which the plane cannot see.
- Not latency, and not a phone. The playground's fork is not the network your players are on. Real-time feel over a real network, on mobile radio, is still unproven, and the plane emulates nothing, so whatever your adapter declares for touch (on-screen controls, the full-frame layout) is exercised only if you put your own browser in device emulation, which flips the coarse-pointer test and is enough to see the layout. Thumbs, and how big a target actually feels, need a real phone.
- What it does prove, precisely: that these exact bytes - this blob, at these seat
counts, with this
cfgSuffix, this bundle built with the arcade's namespace - survive the real bake, register into a real GSP that runs thearcade_alloc(0)gate, and adjudicate a real channel opened by real on-chain moves and relayed signed proofs, for the seat counts you actually played, and that those same bytes boot inside the real shell's iframe at/play/<slug>and talk to it over the bridge. An attach to the public playground has also met pre-flight (preflight.ts), the check the operator runs over your submission (§3 step 6).
And it worked on a playground reserves NOTHING. gameType is globally unique on the real
chain and burned forever on first real registration. A game that works perfectly on your fork can
still lose its name to someone else between your run and your submission, and there is no probe to
warn you, so the first you hear of it is a pre-flight refusal on the submission. Have a second
gameType in mind before you submit; re-identifying is the closed edit in §3 step 1, not a rewrite.
Step 5: build the artifacts
Two pairs: blob/rules.wasm + blob/rules.wasm.sha256 (from blob/build-blob.sh), and
dist/bundle.tar.gz + dist/bundle.tar.gz.sha256 from:
NEXT_PUBLIC_GAME_ID=<the arcade's move namespace> bash scripts/build-export.sh --bundle
NEXT_PUBLIC_GAME_ID is NOT your game key. It is the arcade's shared g/ namespace (defined in
step 4). This is the most common pitfall of the whole flow: built without it, the compiled
move namespace silently falls back to your GAME_KEY, the bundle passes pre-flight and gets
registered, and then refuses to boot, because the SDK's runtime config gate compares the
served gameId to the baked moveNamespace and hard-stops with a clean refusal screen and zero
console errors (sdk/src/components/RuntimeConfigGate.tsx). scripts/build-export.sh refuses to
build a --bundle without it. Bundles are otherwise
deployment-blind: bake only that one env, and endpoints arrive at runtime from
<basePath>/arcade-config.json. Two builds from an unchanged tree must produce the same sha256, on any
machine: the packer pins every tar header field - entry order, owner, mtime, and the file mode
(0644 files, 0755 directories, so a git archive and a group-writable checkout hash alike) - plus
gzip -n (scripts/pack-bundle.sh, called by build-export.sh), and the Next build id is a
content hash (generateBuildId: async () => contentBuildId() in next.config.ts, with
contentBuildId from @xayaarcade/sdk/server). tests/repo/bundle-pack.test.ts runs that packer
on two equal-byte trees with different modes and requires one hash; keep it.
Submit the source commit you tested. Run the strict rules rebuild check and commit the compiled WASM with its SHA-256 sidecar. The operator rebuilds the frontend from that same source commit with the target arcade's namespace; no production frontend bundle or bundle hash is required in the issue. A self-hosted integration run must use the same rules as the submitted commit, but completing such a run is optional.
Reconcile the four registration values here too, by reading. No command does it, so make it a
checklist: the gameType you are about to type equals GAME_KEY in src/app-identity.ts; the
cfgSuffix equals the suffix in your blob/MANIFEST.md byte table and is the same one your golden
traces were replayed under (WASM.md §A4); the seat range is a range you have actually replayed and
tested (§3 step 4, §6); and the slug is the proposed listing slug. Every one of these is a
value a human types once, against a blob that is already frozen.
And play it on a phone before you hand it over, because nothing in the ladder does. Device
emulation at a phone width flips the coarse-pointer test, which is enough to see the real layout and
the real overlay; how big a target actually feels under a thumb needs a real phone. Your adapter must
declare touchControls or fullFrameOnTouch, and your bundle must carry the arcade-manifest.json
that says so (§3 step 3): a bundle that states neither is labelled Desktop only (for now) on the
arcade and a phone cannot start a match in it, and GET /api/games on the playground tells you which
of the two you are before anybody else finds out.
And have someone who has never seen it play it. The check is short: from what the game itself shows, an unfamiliar player can state the objective and make a first useful move without being told. That takes first-turn guidance on the board: what to do now, and what the click they just made did, not only a manual or a help modal, and it is the one acceptance test the ladder cannot run for you.
Step 6: submit a GitHub issue
Use the Game submission form linked in §1. Include:
- Repository and full tested commit containing frontend, rules source, compiled WASM, its SHA-256 file, build instructions and pinned source dependencies.
- Compiled WASM path and hash. The template uses
blob/rules.wasmand its.sha256sidecar. - Game title, description, controls, supported devices and player counts.
- Proposed slug, game type (
GAME_KEY) andcfgSuffixin hex ornone. - Build, rules, frontend and deterministic replay results, including tested player counts, devices and any checks you could not run.
- Whether you want WCHI wagering. Free play is required; wagering needs separate approval. A wagering request names the Polygon address your game's match fee is paid to; you will be asked to show it is yours. You can move it later from that address, and so can the arcade's operator, for a lost key or a delisting.
These are the fields in arcade-submissions/.github/ISSUE_TEMPLATE/game-submission.yml.
The public instructions are https://arcade.xaya.io/docs/submitting. Submitters need a GitHub
account and reviewer access to their source, not a deployment repository or running server.
Do not ask them to supply a production frontend bundle hash: the operator builds the frontend
for each target deployment. The compiled WASM hash must match the reproducible rules build.
The operator checks source, ABI, deterministic replay, fuel, UI behavior and integration on the
private playground before publication. Artifact and metadata bounds are defined by
arcade-platform/submissions/src/preflight.ts (validateMeta, preflight) and the ABI by
arcade-platform/docs/ARCADE-ABI.md. Use the template's build scripts so the frontend carries
its base-path placeholder and the approved move namespace; see §5. Games own no wallet and
must declare their input capabilities accurately.
Keep review discussion on the submitted issue. If changes are requested, post a new tested commit and results there. For a listed game's update, open a new Game submission issue linking the earlier one and describing the changes. The operator reviews and schedules the update; rules changes must account for open channels. An issue does not publish or update a game.
Known template gaps: plan around these, they are not bugs in your fork
- No gamepad layer and no key remapping. Phones are covered as far as input kind and framing go
: a keyboard game declares
touchControlsand the SDK puts a pad and buttons on the screen, a tap-native one declaresfullFrameOnTouchand gets the whole display (§3 step 3), but there is no controller support and no way for a player to rebind keys, in the SDK or the template. Either is work you would do yourself. - The e2e harness is shaped around the template's own game.
e2e/harness.tsand the scenarios carry game-shaped assertions, and the golden-trace generator's scripted move driver is xayaman's move format. Porting them to your rules is its own stage, not a rename: budget for it, and seeWASM.md§A4 for the trace half. - There is NO self-serve update path. Pre-flight refuses a slug or gameType that is already live or pending, so even when uploads open, v1 has no way for you to ship v2, and today the arcade takes no uploads at all. To update a shipped game, open a new Game submission issue (§3 step 6); operator-side bundle swaps take effect immediately.
4. Invariants you must not break
-
Wire-frozen constants. The
arcade_*ABI, the move envelope{"g":{...}}, and the twelvearcade:*postMessage messages the SDK implements (arcade-platform/sdk/src/lib/arcade-bridge/protocol.ts): the eight originals (hello,identity,move-request,move-result,presentation,chrome,chrome-state,theme) keep every field they have forever, and a message only ever gains an optional field that an older parser drops (restoreSlotonchromeandchrome-state, §3 step 3);wager-request,wager-resultandcapswere appended, andmatchmake(§3 step 3a) was appended after them. New messages may only ever be appended. The one exception to the optional-field rule ismove-request.signerName, which is REQUIRED: an unbound request is never signed. The shell answers one with an errormove-resultnaming the problem, and the SDK's ownMoveBroker/BrokeredWriterthrow before posting. Registered game ids are frozen the same way.The wager op list is appended to under the same rule.
WAGER_OPSends with'evictSeat'(carryingmatchIdxand aseatinteger), so an embedded game can broker a permissionless stale-seat removal through the shell exactly like every other wager op: a game in an iframe holds no wallet to send it with. A shell must know an op before a game offers it, because a shell that does not drops the request in its parser and the game would be offering a button whose only outcome is a timeout.WagerBrokerboundsevictSeatat 20 s ("the arcade did not answer the eviction request") so such a shell costs a message rather than a wedged client; every other wager op waits indefinitely, because it is a wallet prompt a player may take minutes over. -
gameIdvsmoveNamespace. A game's identity (configureApp) carries both, and on the arcade many games ride ONE shared namespace. Anything asking "which game is this" against a served config must compare the moveNamespace, and the bundle must be BUILT with the arcade's namespace (§3 step 5), or the config gate refuses to boot it. -
SDK vendoring. The sdk is
@xayaarcade/sdkand arrives asvendor/xayaarcade-sdk-<ver>.tgz. This file states no version: the version is whateverarcade-platform/sdk/package.jsonsays, and any repack MUST bump it, because a same-version repack silently installs stale content from the npm cache. -
NEXT_PUBLIC_*is baked at build time. A hosted bundle must not bake endpoints, dev-wallet flags or arcade origins; the runtime config supplies them (dev-wallet is an OPERATOR decision: on for a fork-testing deployment, off by default).
5. How your game is served: the games origin, the base path, your storage prefix
The serving model, in four sentences. Your game is served from the arcade's games origin,
mounted at /g/<slug>/: that path-mounted URL is what the shell's iframe loads, while the page
a player visits is /play/<slug> on the shell. Your bundle is slug-agnostic: the platform
bakes the path into it at registration, so you never see or choose it. Your UI discovers every
endpoint at runtime from <basePath>/arcade-config.json and never from a compiled-in URL. What
the games origin actually is - a separate origin shared by the games, one origin per game, or the
shell's own - is a per-plane operator decision, and the next paragraph is the part that decides how much a bundle is trusted with.
The shipped planes use separate shell and games origins. Main Arcade loads
bundles from arcade-games.xaya.io; its shell is arcade.xaya.io. Playground's shell is
test-arcade.xaya.io, and it gives every game an origin of its own on a games-only domain,
https://<slug>.xaya.games/g/<slug>/, from games-host's
GAMES_PUBLIC_ORIGIN=https://{slug}.xaya.games (arcade-platform/games-host/README.md). Check the
actual iframe src and runtime config when using another deployment.
In path mode, games-host rejects a games origin equal to the shell's unless the
operator explicitly sets GAMES_SAME_ORIGIN_FIRST_PARTY=1. The shell checks the
same override (games-host/src/server.ts,
xaya-arcade/src/lib/registry/hosted-games.ts). That override is disabled on the
shipped planes. Enabling it grants the framed code the shell's origin privileges.
A separate games origin protects the shell's DOM and storage. Games sharing that
origin still share an origin with each other: paths are not a browser security
boundary. Use your own storagePrefix, authenticate bridge messages, and keep
repository review as part of intake.
The served field set is gspUrl, relayUrl, chainRpcUrl, arcadeOrigin, gameId and
devWallet and relayUrls (every relay of the plane, a one-element list on a plane with one),
plus two written only when they apply: wagerAddress when the row has one, and wagerBeta when
the plane is a wagering beta (games-host/src/handler.ts, the /arcade-config.json branch).
The SDK's frame handles the relay list on its own: it probes each relay, connects to the nearest
and moves to the next after two socket failures in a row, so a game reads nothing from relayUrls
itself. A standalone build lists its relays in NEXT_PUBLIC_RELAY_URLS (comma separated) instead. arcadeOrigin is the shell's origin: it is the postMessage
target the bridge posts to and the frame-ancestors value your bundle is served with. Read it from
the config rather than deriving it from your own location: on a plane with a separate games origin
the two differ, and with the same-origin override they are the same string. wagerBeta is what
puts the beta payout disclosure above the stake button, and only a literal true enables it:
so a real launch turns it off with one JSON edit per plane rather than a rebuild of every game
bundle. devWallet is likewise an operator decision, not yours.
Two consequences of that serving model, and they belong in the same breath:
The base path. scripts/build-export.sh --bundle bakes NEXT_PUBLIC_BASE_PATH=/__arcade_base__
and refuses a caller-supplied override (build-export.sh), because a slug baked at build
time would produce a bundle that only ever serves under one path. Pre-flight rejects a bundle whose
root index.html does not reference that token (preflight.ts): if you hit that
rejection, you almost certainly ran npm run build instead of ./scripts/build-export.sh --bundle.
Next rewrites its own surfaces (next/link, next/image, /_next/*) for you, but it does not
touch a plain <img src="/sprite.png">, <a href="/rules">, a CSS url(/bg.png) or new Audio('/pop.mp3'). Never write a root-absolute URL as a literal and never construct one at
runtime: those are the assets that 404 under /g/<slug>/ and nowhere else.
Your storage prefix. On the shipped planes the games origin is separate from the shell's, so
your localStorage (session keys included) is isolated from the shell's. On Main Arcade it is shared
with every other game on that games origin, where two games with the same storagePrefix write over
each other; under GAMES_SAME_ORIGIN_FIRST_PARTY=1 it is shared with the shell too. Playground's
origin per game hides such a clash, so a bundle that works there can still clash on Main Arcade.
Assume the shared store in every direction. STORAGE_PREFIX in src/app-identity.ts is a literal ('xayaman'
in the template) and stays one: every fork replaces it, in the same edit that sets GAME_KEY,
and setting it to your GAME_KEY inherits that key's global uniqueness for free. Never
'arcade': that is the shell's own namespace and stays reserved. Shipping the template's prefix is
a live bug in someone else's game, not just yours.
Port and slot mechanics are operator-only and live in the platform repo and
/docs/hosting-registration. games-host accepts exactly two modes, port and path
(games-host/src/server.ts); the public plane is path mode, which is the model above.
6. Seats, payouts, and what the chain will actually take
Two different ceilings, and it is easy to blur them. The chain clamps a registration to
2..4 seats (MAX_SEATS, preflight.ts, mirroring MAX_BOARD_PLAYERS in
engine/gsp/wagering.hpp): an out-of-range min/max is dropped with only a log warning, which would
register a permanently dead game. That is the platform ceiling. Your own ceiling is the seat
range your blob has actually been replayed at, and it is the one that matters: a declared seat count
nothing has ever replayed is a channel size nothing has judged. Widening your declaration means
widening your golden traces (WASM.md §A4), not editing a number.
Two optional additive exports change how a finished game pays out. Neither is in the register
gate's required list, so a blob without them registers fine and the host takes a documented
fallback (WasmStateHandle::EjectedMask and WasmStateHandle::ShareWeights in wasm_judge.cpp):
arcade_share_weights(handle, out, cap): per-seat payout weights, LEuint16per participant, written into a 1024-byte buffer (ARCADE-ABI.md§5,ShareWeightsinwasm_judge.cpp). The host rejects any length that is not exactly2 × participants, and a negative return is your blob's "no weights for this terminal" signal. Absent, or returning negative: settlement falls back to plain winner/draw. Read the next section before deciding you do not need it. The client reads the weights too, but only if yourparseStateforwards them: readfinished ? handle.shareWeights(participants) : nullin the samewithHandlecall aswhoseTurn/winner/isFinished(the handle is released when that call returns), and haveParsedBoardState.shareWeights()return that value (sdk/src/lib/channel/board-rules.ts; the PackedJudge section ofsdk/README.md). That forward picks the seat that sends the closing resolution (ChannelManager.getSettlement, published as_settlement) and what the recap says; without it a draw the weights pay to one seat is closed by the lowest live seat and shown as a draw. A client on an older SDK still disagrees about who closes such a draw until the close isCLOSE_FALLBACK_BLOCKSoverdue, when the seat that would close it by the winner alone sends it too; each furtherCLOSE_FALLBACK_BLOCKSadds one more seat, the other seats the weights pay (the most paid first) and last the lowest live seat (Settlement.closers,sdk/src/lib/channel/channel-manager.ts), so a draw that pays several seats does not hang on two clients. Players still reload after the redeploy.arcade_ejected_mask(handle): a bitmask of which seats a timeout ejected. Absent, or returning negative: the host falls back to requiring every participant's signature on any proof not anchored at the current reinit state. Survivors still play on: an anchored proof needs no signature coverage, but proofs can never be pruned again: they grow one transition per move forever, and a pruned proof is rejected on chain. Implement it if your game can eject a seat: Xayaman, Xayatrails and Dungeon Channel do (xayaships, 2-seat, cannot eject), so at more than two seats treat this as the norm rather than an optimisation.
Share weights at three and four seats, not an edge case
If your game seats more than two, this is the normal path, not an exception. Xayaman (2..3),
Xayatrails (2..4) and Dungeon Channel (its engine seats 2..4) all export
arcade_share_weights; Xayaships does not. Taurion Racer also exports both optional hooks
(arcade-taurion-racer/rules/arcade_abi.cpp), but its ejection mask is always zero
for a valid state: exporting the hook does not mean a game can eject players. A 3- or 4-seat game that does not export it settles winner-take-all. That is a
legitimate choice: it is just one you should make deliberately, because nothing warns you that you
made it.
The fallback, precisely, because it decides money. SettleTerminal()
(arcade-platform/engine/gsp/logic.cpp) requires both terminal.ShareWeights(w) and
wager::WeightsValid(w, participants); if either fails it settles on arcade_winner: a seat
index pays that seat (UpdateStats()), -2 is a draw (SettleDraw()), and -1 hands every seat
an equal share of the pot (RefundWageredChannel()). Note what follows: the registry's
drawPolicy is honoured only on that fallback draw: a game's own valid weights always
supersede it. The validator is WeightsValid in engine/gsp/wagering.{cpp,hpp}, the read is
WasmStateHandle::ShareWeights in engine/judge/wasm_judge.cpp, and the absent-export fallback
has its own test at engine/test/wasm_judge_test.cpp.
The unilateral-timeout gate, and it behaves differently above two seats. A finished
terminal naming no executable payout is unusable, and the host then does two different things
(engine/gsp/logic.cpp, ProcessExpiredDisputes): at 2 seats it settles to the survivor; at
3–4 seats it clears the dispute instead. Never SettleDraw, at any seat count, because that would
route the pot through the registry's draw policy and could hand it to the operator. An ABI-legal
-2 is deliberately overridden either way: honouring it would reach SettleDraw → drawPolicy →
the whole pot to the operator off a unilateral timeout, when a real draw-close needs every seat's
signature. The n>2 half is the more surprising one: nothing settles, the clock is simply cleared and
the board and the pot are left exactly as they were, because k seats may legitimately still be
playing and there is no single survivor to pay. Note too that above two seats an unfinished
post-timeout state is the ordinary survivors-continue case and is not unusable at all: only a
finished terminal naming no executable payout is.
Three worked vectors, because the shape is not obvious from the spec:
- Survivor split with placements:
arcade-xayaman/rules/arcade_core.cpp. One survivor →[10000]. Two or more is only legitimate when the match was configured for multiple survivors, else it fails closed and returns no weights. Then either an equal split with the integer remainder going to the lowest-index alive seat, or a by-rank split where rankrofkgets(k-r)parts ofk(k+1)/2, remainder to rank 0. Zero alive returns false and lets the host's draw-refund run. - Winner-take-all with a split draw at four seats:
arcade-xayatrails/rules/arcade_core.cpp(shareWeights, documented inrules/arcade_core.hpp). A decided match pays its winner10000and everyone else0; a draw (-2) splits equally between the non-ejected seats holding the most round wins, remainder to the lowest-indexed paid seats, so an ejected seat is never paid. It returns false on an unfinished state, an undecided or out-of-range winner, or a draw in which every seat is ejected. Copy this one if winner-take-all is your answer for a decided match: it states that choice in the export and still gives a draw an executable payout, rather than omitting the export and leaving both to the fallback. - Verdict-aware split:
arcade-dungeonchannel/rules/dungeon_core.cpp. A single winner takes10000; otherwise split evenly among seats that are standing and not ejected; if none, among the dead that are neither ejected nor convicted: a convicted cheater must not collect from the grave it dug; if none, among all non-ejected; remainder to the lowest eligible seat. The weights and the winner derive from one place, which is what stops a fraud verdict paying the cheater.
Fail closed harder than the host requires. Dungeon Channel's ABI wrapper independently verifies
its own weights sum to 10000 and returns -1 if not (arcade-dungeonchannel/rules/dungeon_abi.cpp).
The host would have rejected them anyway, but returning -1 yourself means the fallback you
chose runs, rather than one you did not.
The 2-seat draw, unchanged and still the case people get wrong. On the 2-seat dispute-expiry path
the host deliberately does not honour arcade_winner's -2: a finished -2 with no weights is
unusable and settles winner-take-all to the seat that did not time out. A 2-seat draw must therefore
be spelled [5000, 5000] in arcade_share_weights, which splits between the players; weights
outrank the arcade_winner readout on every close path (ARCADE-ABI.md §2, "One documented
exception").
At more than two seats, a timeout is a reinit, not a settlement. When a dispute expires at n>2
and your arcade_resolve_timeout output is not finished, the host installs those bytes as the
channel's new reinitialisation basis (engine/gsp/logic.cpp: UpdateMetadataReinit +
Reinitialise, not another state proof): the reinit id rotates, every pre-ejection signature dies,
and the required signers become the parsed reinit's participants minus your arcade_ejected_mask
bits. Obligations the host does not validate: never return a negative for the on-clock seat you
are passed (the dispute re-runs every block and stays pending forever); the post state must be
finished or expose a live non-ejected whose_turn (a not-finished NO_TURN state can never be
disputed again); turn_count must not regress; and carry the mask through arcade_apply_move. The
full contract is arcade-platform/docs/ARCADE-ABI.md §§2-3.
7. Operating the platform is not your job
You build a game; the operator runs the host. The full infra runbook - bringing up the host GSP
(arcaded) and the relay, registering rules blobs and UI bundles by hand, and running the
submissions service - lives in arcade-platform at docs/RUN-FROM-SCRATCH.md. This skill covers
building a game, not operating the platform.
Shared reads and relay scaling
Each Arcade plane runs a private reads service from xaya-relay/src/read-server.ts. Its confirmed
and pending producers share in-memory snapshots; public /gsp requests and the shell's
/api/gsp-rpc alias cannot start referee long polls or state exports
(xaya-relay/src/read-service.ts and the plane's front proxy). Public GSP requests are single
JSON-RPC calls, not batches. Failed or stale upstream state returns an error; do not interpret it as
an empty registry or available wagering queue.
The relay uses WebSockets for subscriptions and signed game traffic. It reads the shared service; clients retain the SDK's HTTP fallback. Relay joins and wallet RPCs have bounded admission, and slow sockets have a backlog limit. Use the SDK's reconnect and resynchronisation behaviour rather than adding an independent polling loop per game. Re-read the state after a disconnect.
The public chain gateway allows wallet methods explicitly and coalesces cacheable reads. Pending reads and transaction submissions are uncached. Operators can give the public chain gateway a provider of its own, separate from the connector's; sharing a key still shares its quota. Keep the raw referee and provider credentials private.
Connection limits are not measured player capacity. Test state size and change
rate, active channel count, game traffic, latency, reconnects and upstream outages
on representative hardware. Full-state broadcasts still grow with the audience.
A plane may serve several relays (relayUrls); the SDK picks the nearest and fails over (§5),
and relays linked by LINK_PEERS forward frames between each other (xaya-relay/src/mesh.ts),
so two peers need not share a relay.
8. Quick answers
- Where do players' wallets and names come from? The shell (xaya-arcade) owns Connect and the XAYA name. Games receive identity over the bridge and request move signatures; they hold no wallet.
- Why did that move go through with no wallet popup? Smooth Play: the player armed a session key
in the arcade and the shell signed the move with it instead of prompting. It is entirely
shell-side: same request, same
move-result, and the key never reaches your frame. §3 step 3 carries the three rules a game has to respect, and all three are "keep doing what you were doing". - How do I get my game on the arcade? Test it, then open the Game submission form in §1 with the exact source commit, WASM hash and game details. Self-hosting is optional (§3 step 6).
- How long until my game is live? Publication follows source review and private integration testing. Follow the issue for requested changes and the outcome; no deadline is guaranteed.
- How do I update my shipped game? Open a new Game submission issue with the tested commit, a link to the previous issue and a change summary. The operator reviews and deploys it.
- Channel logs are silent: how do I debug? Hot-path channel and relay logging is gated. Build
with
NEXT_PUBLIC_DEBUG_CHANNEL=1, or at runtime set thelocalStoragekey<storagePrefix>_debugto'1'and reload (the prefix is the one you passed toconfigureApp: e.g. prefixtrailz→ keytrailz_debug).debugEnabled/dlogare SDK exports. - How does my UI find the GSP and the relay in production? It doesn't:
<basePath>/arcade-config.json, served next to your bundle, carries them (§5). - N players? Rules are N-ready; you declare your seat range in the attach form (§3 step 4) and in the
submission (§3 step 6): never in code. The chain accepts 2..4 (
MAX_SEATS,preflight.ts) and your blob's proven range is the real limit (§6); the lobby respects it. - Presentation and immersive mode? Declare it on the adapter (§3 step 3). The shell letterboxes:
except on a touch device when you declare
fullFrameOnTouch, where it gives you the whole frame: hides chrome on request, and pushes theme tokens (--arcade-*). If your board is one square surface,presentation.squareBoardhands the frame to the SDK and you delete your own copy of that CSS. Never rely on the shell answering: standalone and dev must still work; graceful degradation is contract. - How do players find each other? The arcade's own cross-game lobby: one lobby spanning every
game, free until two players agree. Your game receives the agreement over
arcade:matchmakeand the SDK's banner acts on it; you implement no matchmaking. §3 step 3a is the whole flow, including why your own create card is folded behind Advanced when embedded. - Wagering? A platform module, not game code: your rules and UI stay wager-free. The live ladder
uses the tiers and windows from
resolveRegistryTermsinarcade-platform/submissions/src/chain.ts; see this skill'sWAGERING.md§1. - My game has secrets / simultaneous moves?
HIDDEN-INFORMATION.mdandCOMMIT-REVEAL.md. Read the first one before putting anything hidden in a board state: every seat holds that state. - Golden traces?
WASM.md§A4: the only home for the procedure.
Authority files for this document: arcade-platform/{engine/judge/wasm_judge.cpp, engine/gsp/logic.cpp, docs/ARCADE-ABI.md, submissions/src/*.ts, games-host/src/*.ts, sdk/CHANGELOG.md, sdk/}, arcade-xayaman/{src/app-identity.ts, src/bootstrap.ts, scripts/build-export.sh, blob/, e2e/suite.ts, package.json, README.md}, and for the per-game examples arcade-xayaships/src/lib/arcade/presentation.ts (the touch-layout measurements), arcade-xayatrails/{blob/MANIFEST.md, rules/arcade_core.cpp}, arcade-dungeonchannel/{blob/MANIFEST.md, rules/dungeon_core.cpp, rules/dungeon_abi.cpp, src/lib/dungeon/secret.ts}; the shell-side claims come from xaya-arcade/{src/lib/smooth-play/, src/components/play/GameFrame.tsx, src/lib/registry/hosted-games.ts} and the relay's liveness sweep from xaya-relay/src/limits.ts. Read the file, not this summary, whenever a claim decides something irreversible.