Ship to the Arcade

The one file to read if you just want to ship a game: the whole flow from a template clone to an accepted submission.

Raw markdown →

Ship a game on the Xaya Arcade

The one file to read if you want a game live. Everything here is a builder action; nothing here needs an operator.

0. Authority — where every claim in this file comes from

QuestionAuthority
The judge ABI: which exports, which caps, what a trap meansarcade-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 limitsarcade-platform/docs/ARCADE-ABI.md
Every submission bound — sizes, patterns, seat clamp, reserved namesarcade-platform/submissions/src/preflight.ts
How a bundle is mounted and what a base path meansarcade-platform/games-host/src/{bake.ts,handler.ts,server.ts}
The narrative reference for all of ithttps://arcade.xaya.io/docs — public, no login, no repo access (source: xaya-arcade/content/docs/)

Read that last one first if you do not have repo access yet. Four of the five authorities above are paths inside arcade-platform, which is private during the curated phase — but the site's /docs is public and carries the parts you need soonest, in readable form:

You needPublic page
Who to ask for access, and the tooling versionshttps://arcade.xaya.io/docs/requirements
The clone command, npm ci, and a tour of the treehttps://arcade.xaya.io/docs/getting-started
The full arcade_* signatures and their sentinel valueshttps://arcade.xaya.io/docs/rules-blob
GameAdapter, configureApp, the presentation declarationhttps://arcade.xaya.io/docs/sdk

This skill is the working summary; /docs is the narrative. Verify at source when it matters — docs go stale the same day. Every number below names the file it came from in the same sentence; if a number here has no authority beside it, treat it as missing, not as true.

1. What the Arcade is — start here with zero access

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) — you never deploy infrastructure, never write wallet code, and never touch the chain directly. One Accept by the operator runs the whole go-live pipeline (re-preflight → hosting → on-chain registration → site listing) with no site rebuild, and the game is playable within seconds.

The arcade is in its curated phase, and that decides your whole route to the shelf. The arcade takes no self-serve uploads today, and its own pages say so: arcade.xaya.io/submit serves a "this arcade takes no uploads yet" page instead of a form, and /build's Publishing section sends you to the playground and the contact channel rather than to an upload (xaya-arcade/src/components/submit/SubmissionsClosed.tsx, src/app/build/page.tsx — both keyed off whether a moderation service is wired at all). The service behind them is closed to match: every /api/submissions route on arcade.xaya.io answers 503 (xaya-arcade/src/lib/api/submissions.ts, submissionsBase() / unavailable()), so there is no queue there to join and nothing to upload to. Open publishing arrives with Arcade v2. Until then a game reaches the shelf in two steps, both open right now:

  1. Attach it on the playground. https://test-arcade.xaya.io/attach runs the same pre-flight, the same bake and the same go-live pipeline as the real arcade, on a disposable forked chain — but it auto-accepts: no moderator, pre-funded test wallets, playable minutes later, with a card on that plane's /games and your bundle framed in its shell at /play/<slug>. Attach there and play a full match at every seat count you mean to declare (§3 step 4). Keep the exact bytes that worked; the plane wipes every Monday at 04:10 UTC (§3 step 4), so nothing you attach survives there.
  2. Get in touch to have it listed. Raise the game in #builders on Discord with your repository, your four registration values (§3 step 0) and the bytes that played, and the operator takes it from there. That channel is also where you ask for access to a repo that will not clone.

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 platform can serve a game from an origin of its own, which is what keeps a bundle's storage out of the shell's — but a plane whose every bundle is first-party may declare that and serve the games from the shell's own origin instead, and the arcade does exactly that today: your game is mounted at /g/<slug>/ on arcade.xaya.io (§5). On such a plane an accepted bundle shares the shell's realm, so the human read of your repo is not a second line of defence behind a browser boundary — it is the only thing deciding what runs in the frame at all. Nothing about a name is claimed or burned until an operator registers your game, so being turned down is recoverable (§3 step 0).

Try the clone first, because if it 404s that is the one item in your schedule with a lead time. Repo visibility is per-repo and it changes, so run the clone before you plan around it. A 404 means the repo is not open to your account yet: ask in #builders on Discord for access to xaya/arcade-xayaman, with the account you will clone with, and start that request before anything else — waiting on a human is the one thing you cannot compress. Everything at https://arcade.xaya.io/docs is public and readable either way, so start with /docs/requirements and /docs/rules-blob while you wait.

No key and no funds, though — those really are zero. Building, testing and the whole playground dry run need no wallet, no p/ name and no WCHI; 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 you upload. 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, and this file quotes them in §3 step 0 and §3 step 6. The cheap way to find out early is the disposable playground (§3 step 4), which runs the same pre-flight and auto-accepts, so an answer comes back in seconds.

2. The repos

The template, the platform and the shell — the three you touch. The first-party games, five of them now, are a reading list rather than a table here: they live in this skill's EXAMPLES.md, one row each saying what that game is the reference for.

RepoWhat it isWhat you do with it
arcade-xayamanTHE copy-me templateFork this one. Its README is the 10-section builder guide; §9 is Make your own game from this and §7 is the build/bundle/register run.
arcade-platformthe host GSP (arcaded), @xayaarcade/sdk, the games-host bundle server, the submissions serviceCheck 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-arcadethe shell site (wallet, XAYA name, cross-game lobby, the closed submission front, embed + presentation)Consumed, never forked.
arcade-wagerthe deployed ArcadeWager.sol the arcade's stakes escrow intoRead-only, and only if you want to reason about payouts. Wagering is an operator switch, not game code — see WAGERING.md §1.
a fork-testing deploymentthe operator's forked-EVM deployment repo — the whole stack, no real funds. Not publishedNothing: it is operator-grade infra, and no step below needs it.

What you actually need: a fork of arcade-xayaman — you only ever fork that one repo — and a checkout of arcade-platform for the SDK tarball and the ABI reference. Everything else is reading material.

Repo URLs, and which of these are public today, live in this skill's EXAMPLES.md. Do not assume visibility from a name: most xaya/* repos are private during the curated phase — access on request. If a git clone fails with a 404, that is what it means; it is not a typo in the URL.

git clone https://github.com/xaya/arcade-xayaman.git

If any clone in this file fails, stop and ask rather than working around it. Post in #builders on Discord with the repository name and one line on what you are building. 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 constraints in §1 — 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

Node20.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)
Dockerrequired — 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 3any — 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 WCHInot needed to build or to dry-run. 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 at upload — typed into the playground's attach form, or carried in the meta field of the multipart POST (§3 step 6) — and nowhere else. No file in your repo declares them, no build step reads them, and nothing validates them until the upload. 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.

factpermanencewhen 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 URLnow, before code
seats (min..max)operator can re-register it; no self-serve pathprovisional — settle it when your goldens do
cfgSuffixoperator can re-register it — unless a channel of your game type is open, and a wrong suffix wedges every channel part-filled, which is an open channel. The mistake creates the state that blocks its own fix.provisional — derived from rules that do not exist yet (§3 step 2)

When the names are checked: at upload, by pre-flight, and not one moment earlier. There are exactly three name checks and they all live in preflight.ts:

  • a slug in the first-party RESERVED_SLUGS list (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 slug already held by a live game or another non-rejected queued submission is refused;
  • a gameType the 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 an upload claims it, and it is not permanent until an operator Accepts.

So find out cheaply instead of guessing: dry-run on the playground first (§3 step 4). It runs the same pre-flight and auto-accepts, so a reserved slug or a taken gameType comes back in seconds, before you have had to ask anybody anything. Keep the resubmitToken it returns — on that plane it is what lets you re-upload the same (slug, gameType) while you iterate (preflight.ts).

The relief valve, in the same breath, because you will want it: nothing is claimed until an upload carries the name, nothing is burned until an operator registers your game, and a rejected row releases both names — the claim checks skip rejected rows, so a builder who is turned down fixes and re-uploads under the same slug and gameType. Only acceptance 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 its README §9, Make your own game from this. Two starting points, and its §9 splits them the same way:

(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. Delete rules/heritage/ together with rules/ — heritage is a test oracle valid only against the rules it was ported from — and read the pitfalls table in this skill's PITFALLS.md first: the heritage README's removal note is incomplete, and the Makefile needs more surgery than "delete the two targets".

The identity surface, closed. The template's README §9 names three anchors and then sends you to https://arcade.xaya.io/docs/make-it-yours for the maintained walkthrough. These eight are the subset that fails silently if missed, so do them in one edit:

#editwhat changes
1src/app-identity.tsGAME_KEY, TITLE, STORAGE_PREFIX — the one source of every id. MOVE_NS derives itself; leave it.
2package.json"name" (plus description/repository for tidiness)
3tests/repo/copyright-guard.test.tsthe self-identity pin block at the top — pkg.name, GAME_KEY, STORAGE_PREFIX, deliberately a second independent copy that exists to catch a half-finished rename. Leave the forbidden-title sweep below it untouched.
4-6docker/Dockerfile.frontend, docker/frontend.env.example, scripts/build-frontend-image.shthe NEXT_PUBLIC_GAME_ID build-time default
7package.json's arcadeGameLibDirnames your game's lib dir (src/lib/xayaman in the template). Rename it with the directory below, in the same commit: e2e/imports.test.ts derives the path it sweeps from this field, and a stale value makes that gate fail closed rather than sweep the wrong tree.
8src/lib/<game>/the template's game directory is src/lib/xayaman/; rename it and every import of it, in the same commit as #7.

Eight rows is the closed part, not the whole surface. A real re-identification reaches well beyond them, and the rest fails loudly rather than silently: once your declared identity diverges from the template's, the copyright guard's template-identity sweep (tests/repo/copyright-guard.test.ts) lists every remaining tracked file that still carries the template's name or game key — the vendored vendor/ and .claude/skills/ trees and the rules/+blob/ you may keep verbatim are exempt, nothing else is. Five facts to know before the first run:

  • The template's name is refused anywhere, a README link to github.com/xaya/arcade-xayaman included. Credit the template without spelling its name, or not at all.
  • package-lock.json carries the package name twice — the root name and the packages[""] entry. Edit both fields by hand; no reinstall and no registry access is needed.
  • Only tracked files exist to the gates. The sweep and e2e/imports.test.ts both read git ls-files, so a new directory that is not yet git added reports as "no tracked files under <dir>" rather than as a rename. Stage new files before running the ladder.
  • Include every header you use in rules/. The wasm build (wasi-sdk) and the native test build resolve transitive includes differently, so a size_t that arrives for free under one toolchain is 'size_t' does not name a type under the other; a green build-blob.sh does not prove the native battery compiles.
  • 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 first blob/tests/run-tests.sh needs the network: it builds the test image, and blob/tests/Dockerfile.testenv downloads the pinned wasmtime C API during that build. The image is large because it derives from the libxayagame image for the heritage oracle's dependencies; a fork that has deleted rules/heritage/ needs only a C++ toolchain, libjsoncpp-dev and wasmtime, and can base the image on a slim Debian instead.

Budget for that, and expect the ladder — not this list — to be what tells you when you are done.

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:

importfor
@xayaarcade/sdkyour app code — React screens, hooks, the bridge, wallet + chain.
@xayaarcade/sdk/coreyour shared code — BoardRules, your OpenChannel, your packed codec. Isomorphic: it evaluates in the browser bundle and under a bare npx tsx.
@xayaarcade/sdk/e2enode-only scenario infrastructure: on-chain moves, WsBroadcast, RecordingTxSender, quietChannelLogs().
@xayaarcade/sdk/servernode-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. Before SDK 0.7.0 it did not exist, and every game repo had independently built a two-file sdk-proto.ts/sdk-proto.node.ts twin behind a package.json imports map to reach one protobuf schema. If you inherited that pattern from an older template, delete it and import from /core.

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 (edit #7 above) 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 the registerAdapter(GAME_KEY, …) call; the registry key is GAME_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-identity exactly as its header promises, so it usually needs no edit — verified.)
  • The rest of e2e/, which carries far more game shape than the two files above suggest. e2e/suite.ts contains a whole scenario written around the template's own mechanics plus board fields another game does not have; e2e/match-cfg.ts owns this game's cfg fields, encodeCfgSuffix/cfgSuffixHex and PROD_CFG_SUFFIX, and hands them to the SDK's ChannelConfig.build() — it is the single most game-shaped file in e2e/, 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; e2e/onchain-scenarios.ts only imports from it; e2e/onchain-scenarios.test.ts pins the literal cfg hex; e2e/onchain-scenarios.smoke.test.ts asserts the blob accepts the production cfg and rejects the empty one via prodCfg/ PROD_CFG_SUFFIX — no cfg edit once match-cfg.ts is yours, but its assertions are game-shaped; e2e/packed-determinism.ts carries its own cfg builder, field names and trace filenames; and e2e/selfplay.ts imports the template's bot.
  • A second game directory the row above does not name: src/game/ (types, and any mechanic module beside them) is separate from src/lib/<game>/ and is just as game-shaped.
  • src/hooks/use-<game>-input.ts, and src/lib/arcade/presentation.ts's exported presentation constant.
  • The game-shaped unit tests under tests/lib/, tests/hooks/ and tests/components/ — rung 1 of the ladder runs them, so they are not optional.
  • Stale xbm/xayaman words 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 is the one in that list 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's imports.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 _initialize as a function and memory as an exported wasm memory (wasm_judge.cpp's kRequiredFuncs + 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 §A1 or, with no repo access, the public https://arcade.xaya.io/docs/rules-blob. The sentinels are the part that bites: arcade_winner returns -1 undecided, -2 draw, 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:

  1. 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 whatever cfgLen values you accept is your config contract.

    participants is not always your seat minimum. A blob that hard-codes its seat count and rejects participants == 1 compiles, passes its own unit tests, clears the register gate — and then dies at setup in the template's e2e suite: buildWorld seeds every scenario with judge.initialState(numPlayers, prodCfg(seed)) — a ChannelConfig built by the SDK, never loose bytes — and throws when it comes back null, and scenario 7 builds its world with buildWorld(1, 42) (buildWorld in e2e/suite.ts). Accept every count in [1 .. seats.max], answer the one-seat board as valid, and have arcade_whose_turn say 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 (HandleCreateChannel calls Reinitialise (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 to arcade_initial_state is clamped into your registered min..max before 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.

  2. The host builds cfg = LE32(channel seed) ++ cfgSuffixbut only when the suffix is non-empty. With an empty suffix it skips the seed prefix entirely and hands you a 0-byte cfg (BuildCfg in engine/gsp/logic.cpp; the Config section of the template's blob/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 exactly 01, a single version byte that must never be empty (arcade-dungeonchannel/blob/MANIFEST.md), which is the cheapest way to buy LE32(channel seed).

  3. Write the layout down in your own blob/MANIFEST.md as a byte table. Two shipped examples bracket the space above the single version byte of point 2: xayaman's two-field suffix 0200000001000000 (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). The cfgSuffix you submit is that layout minus the leading LE32 seed, hex-encoded, lowercase, even length, and at most 128 hex chars (CFG_SUFFIX_MAX in preflight.ts).

  4. 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 (validateMeta in preflight.ts). With no suffix registered the host hands arcade_initial_state zero bytes and no seedChannelConfig.build(seed, '') mirrors that and drops the seed — so your blob must accept cfgLen == 0 and reject every other length. The inverse mistake is the silent one: register a suffix for a blob that wants none and every channel receives LE32(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. Two worked zero-byte examples: arcade-xayaships/blob/MANIFEST.md, and Vector Sumo, whose blob rejects every non-zero cfg length because the game has no randomness and needs no seed (arcade-vector-sumo/blob/MANIFEST.md).

  5. Copy the value into the form (or into meta) from your MANIFEST.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_state turns bytes into a handle; arcade_is_valid answers whether that handle is a state your rules would ever have produced. The host treats handle == 0 || is_valid == 0 as 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 66,300,000 fuel units, each instance is bounded at 64 MiB, and arcade_apply_move / arcade_initial_state / arcade_resolve_timeout write into an 8192-byte output buffer — a returned length outside 0..8192, including the -2 "too small" answer, is a terminal reject with no retry (docs/ARCADE-ABI.md §5, matching kFuelCap in engine/judge/wasm_judge.hpp and kMemoryLimitBytes and kCap in wasm_judge.cpp). The judge is the authority for the 8192-byte cap; the template's test host blob/tests/wasm_host.hpp mirrors 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 calls arcade_alloc(len) with the input's real length, so an empty move/state/cfg calls arcade_alloc(0), and a 0 return there is a reject (docs/ARCADE-ABI.md §5, "Allocation sentinel"). As of SDK 0.16.0 the in-browser judge does the same thingarcade_alloc(len) and arcade_free(ptr, len) with the true length — so both judges refuse an empty input identically. A bundle built on 0.15.5 or older still asks for alloc(len || 1), which is exactly how a blob whose arcade_alloc(0) answers 0 plays fine in the browser and rejects on chain. 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 calls arcade_alloc(0), and a blob that answers 0 — or traps — is refused registration outright instead of failing later on its first empty input. The /attach 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 upload is not a clean gate. A naive malloc(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 — answer arcade_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.

  • Three different ids, and they are not the same string. GAME_KEY is the GSP registry key and the adapter registry key — it is what your lobby filters channels by. The move namespace is the arcade's shared g/ namespace, which many games ride at once. storagePrefix is your localStorage namespace on the games origin (§5). The template derives all three from src/app-identity.ts so they cannot drift.

  • One GameAdapter plus 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.

  • The presentation declarationpresentation?: GamePresentation on the adapter: aspectRatio, minViewport: {width,height}, immersive?: 'never' | 'preferred', fullFrameOnTouch?: boolean and squareBoard?: boolean (GamePresentation in sdk/src/lib/games/adapter.ts). The adapter is the source; the arcade:presentation message 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 at upload (§3 step 6).

  • 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 aspectRatio only when the board has announced its presentation and the box is at least minViewport (xaya-arcade/src/components/play/PlayShell.tsx). On a touch device a game declaring touchControls or fullFrameOnTouch gets 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's src/app/globals.css pins html, body to 100dvh with overflow: hidden and overscroll-behavior: contain, and why squareBoard sizes 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 document document.documentElement.scrollHeight <= clientHeight and scrollWidth <= clientWidth (a browser automation tool reaches the frame through its frame API; production code never can).

  • squareBoard, if your board is one square surface (SDK 0.17.0). The SDK owns the square board frame — the largest square that fits both axes, with the strips the status line, the on-screen controls and the controls hint sit in reserved on an upright phone. Declare presentation.squareBoard: true and 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. 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; put class="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 — a ResizeObserver, a getBoundingClientRect for 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 in src/lib/arcade/presentation.ts.

  • 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: touchControls for a keyboard game and presentation.fullFrameOnTouch for 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 an uploaded bundle. The games-host stores the answer on the manifest row as a touch boolean, and it takes that answer from the bundle itself: a file named arcade-manifest.json at the archive root of bundle.tar.gz, containing {"touch": true} (BUNDLE_MANIFEST and bundleDeclaresTouch in arcade-platform/games-host/src/touch.ts). The template ships it as public/arcade-manifest.json, which the static export copies to the bundle root, and its adapter test asserts the file's touch equals what the adapter declares — keep the two in step. The bundle's own statement outranks every other source (games-host/src/registration.ts); the src/ scan (checkoutDeclaresTouch) applies only when an operator registers a game 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. The playground's attach page says the same back to you the moment a game is accepted, and GET /api/games on that plane shows the stored touch for your slug. Nothing is refused over it. The five shipped answers split three to two. Three declare touchControls: 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 in src/lib/dungeon/input.ts). Two declare fullFrameOnTouch: true and mount 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 behind useCoarsePointer(), and Vector Sumo (arcade-vector-sumo/src/lib/arcade/presentation.ts), whose board draws its own aim pad, action row and commit button for every pointer type (arcade-vector-sumo/src/components/VectorSumoBoard.tsx). Vector Sumo's adapter records why it refused the generic pad, and it is the reason to read it: a mounted pad lands in the same bottom gutter its own controls occupy, on top of the Commit button that seals an irrevocable choice — and the one control that has to report state cannot be a key-synthesizing button (arcade-vector-sumo/src/lib/games/vector-sumo-adapter.ts).

  • On-screen touch controls, if your game is keyboard-driventouchControls?: TouchControlsSpec on the adapter (sdk/src/lib/games/adapter.ts): an optional 4-way dpad and optional action buttons, where every value names the KeyboardEvent.key it stands for. A dpad names all four directions or is absent — a two-direction game declares buttons instead — and a button's key is held for as long as the button is pressed (keydown on press, keyup on release), the same level-sampled model a held keyboard key gives your hook. Declare it and ChannelGame mounts 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 with fullFrameOnTouch rather than by mounting a pad it has no use for.

    Three shipped specs to copy from, and they differ mostly in how many buttons they dare to mount. Xayatrails declares a bare d-pad and nothing else (arcade-xayatrails/src/lib/games/xayatrails-adapter.ts); xayaman a d-pad plus one action button (arcade-xayaman/src/lib/games/xayaman-adapter.ts); and Dungeon Channel a d-pad plus three — attack, torch and use (arcade-dungeonchannel/src/lib/dungeon/adapter.ts, spec in src/lib/dungeon/input.ts). Three action buttons is the practical ceiling on a phone — past it the pad and the buttons start competing for the same thumb-reachable band, and the board loses the space instead. Budget the buttons before you design the verbs, not after. The two games that declare no spec are the counter-example worth reading beside these: a board that is already the input surface asks for fullFrameOnTouch instead, and mounting a pad over it would cover the controls it draws itself.

  • controlsHint is the line your win condition lives on, and since SDK 0.18.0 it can be worded per device. The field is string | { keyboard, touch } (sdk/src/lib/games/adapter.ts). A plain string renders everywhere exactly as it always did; 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 since 0.17.0 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. [BREAKING, 0.18.0] any code that READS the hint as a string — .length, .toContain, passing it where a string is required — no longer compiles (TS2339), and every game repo's own adapter test does this. Fix a reader by narrowing (typeof hint === 'string') or by resolving it first: resolveControlsHint(hint, coarsePointer) 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 (as of SDK 0.11.0) and the bridge skips the arcade:presentation post on a coarse-pointer device, so the shell leaves the game the entire frame instead of letterboxing it to aspectRatio. A fine pointer is unaffected, and a game that declares touchControls already gets the skip implicitly — its pad needs the game's own gutter to sit in — so it never sets both. That inverse relation is why exactly the two first-party games that mount no pad declare it: xayaships and Vector Sumo, both in src/lib/arcade/presentation.ts. 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 (as of SDK 0.11.0), 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: a matchMedia read in render scope gets memoized and frozen at false by the React Compiler your frontend builds with. The hook reads in an effect, so it answers false on the server and on the first client render and flips after mount.

  • If you hash in the browser, pick your SHA-256 deliberately — crypto.subtle needs a secure context. It exists only under HTTPS or a localhost origin, and is undefined when 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/hashes precisely so a bundle works over a LAN IP (sdk/src/lib/crypto/sha256.ts states the reason) — but your own code might. The three shipped answers: import the SDK's async sha256 from @xayaarcade/sdk/core and it works everywhere (arcade-xayaships/src/lib/ships/ships-arcade-channel.ts); ship a synchronous implementation of your own when you need one inside a synchronous path such as a localStorage validation (arcade-vector-sumo/src/lib/vector-sumo/sha256.ts, whose header states all three of its reasons); or — if you genuinely want Web Crypto — gate at boot with a message naming the fix, never let it fail per-frame (arcade-dungeonchannel/src/game/game-shell.ts, which refuses to start and tells the player to use HTTPS or an SSH tunnel to localhost). Note the consequence: vsm plays over plain-HTTP LAN and dch does not, by choice, in the same arcade. 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. Only crypto.subtle is secure-context-only. A game that reaches for subtle when 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.ts lists 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), and, as of SDK 0.17.0, ejectedSeats: 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 no turn — bridge both from the sources that have them. GameRenderProps carries only localPlayerIndex (sdk/src/lib/games/adapter.ts); the live submit is handed to your useInput hook alone, and ChannelGame calls 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 the submit it was given in module scope and the board calls a plain function that forwards to it, dropping calls made before the channel is ready (arcade-xayaships/src/hooks/use-ships-input.ts is the shipped shape). Likewise the store publishes no whoseTurn: the template's board reads a turn field its packed state happens to carry (board-state-converter.ts). If your packed state derives whose turn it is, carry the judge's own answers — whoseTurn(), winner(), isFinished() — through your BoardRules' 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. Since SDK 0.15.1 a tab parked on a lobby does essentially nothing between blocks — an arriving state identical to the last one reuses what was already parsed, the wager lobby and the balance readout compare field-by-field and say nothing when the answer is unchanged, and the 15-second balance refresh no longer flips loading. 0.15.3 added block ticks — a client tells the relay it can take a ~100-byte tick instead of the full state on a block where only the header moved, so a countdown stays live while the rest of the screen sleeps — and 0.15.4 moved the open-match list to a relay push, since that list is the same for everybody. Nothing in a game changes for any of it, and all three degrade cleanly against an older relay. What a game must not assume: that useGspPolling() still hands you the chain tip (see the breaking change below), that a poll interval is running (a pushed tab runs no timer), or that a full state arrives every block. [BREAKING, 0.15.1] useGspPolling() no longer returns height or blockHash. Read them from useGspBlock() (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 entire point, and hoisting it back up re-creates the bug it fixed. [BREAKING, 0.15.2] WagerGspInputs lost its height field (use-arcade-wager.ts); nothing ever read it, but a lobby still supplying it will not compile. And one addition, for anyone decoding a relay document outside the hook: wagerDocMatchesFor(doc, gameType) (sdk/src/lib/wager/wager-reads.ts, re-exported from the main entry).

    SDK 0.17.0 finished the job on the other side of the wire: consecutive parses of a pushed state now share every channel and every collection whose exported JSON is byte-identical, so a pushed state costs only what actually changed in it and React bails out of a redraw rather than rebuilding the screen. Nothing in a game changes for that either — parseGameState(raw, cache?) takes an optional carried-over cache (newGspStateCache(), sdk/src/lib/chain/gsp-client.ts) and, called without one as a single read does, parses independently exactly as before. [BREAKING, 0.17.0] useChannelManager's moneyErrors / dismissMoneyError are now sendErrors / dismissSendError, and they carry every failed on-chain send rather than a paid channel's only — a dispute, a resolution or a win submission that fails now lands in the in-match notice column with the reason instead of going to the console. Rename the two fields; nothing else about them changed. Only an app that builds its own board screen on the hook is affected — a game that renders ChannelGame, which is every shipped game, needs no edit.

  • 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). Since SDK 0.16.5 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. A bundle on an older SDK treats a server-sent 1000 as its own hang-up, stops listening, and never learns its own match has filled. On top of that: reconnects back off to a ten-second ceiling and dial immediately when the browser reports its network back (sdk/CHANGELOG.md, 0.18.4); a match open in a second tab is now named as such, with one Play here button that takes the seat back, instead of the two tabs evicting each other (0.18.4); 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 (0.18.4); 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…" (0.18.6). 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 a peer_left within 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.wasm runs 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 inside dist/. So src/app/globals.css carries @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. Run npm 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 @source line 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 as of SDK 0.8.0 — it binds the move so the shell refuses to sign under a different arcade identity. BrokeredWriter supplies it from the move's own args[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 at src/components/play/GameFrame.tsx). Nothing about the bridge changes: you still post arcade:move-request and still get one arcade:move-result carrying 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, and on a first-party plane that is not the same as unreachable. No bridge message carries it, none ever will, and a game must never ask for one, cache one, or sign a move itself — the bridge messages are frozen and additive-only (§4). But the shell keeps the derived burner in its own localStorage, private key and all (xaya-arcade/src/lib/smooth-play/session-key.ts, the arc.smoothplay.* record), so where the games are served from the shell's origin that store is the same one your bundle can read. Nothing in your game may go looking, and this is precisely why hosting on that origin is gated on the human read of your repo rather than on a browser boundary (§5). 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 a move-result as 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 — the g namespace plus one game id — so the shell routes a move only when it parses as exactly that: one top-level g, exactly one id under it, an object payload, built with JSON.stringify. BrokeredWriter already produces that shape, and anything else (a sibling key smuggled beside g, a non-object payload) is not a failure — it simply takes the wallet path and costs the player a prompt.

  • Animate from a requestAnimationFrame loop reading refs or stores, never from render scope. Game frontends build with reactCompiler: 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's PITFALLS.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.

  1. The message. After arcade:caps, the shell may send { type:'arcade:matchmake', role, seats, tier, peerName, ts }. role is 'create' or 'join' — which half of the agreement this player took; tier is 'free' or the agreed stake in WCHI base units; seats is 2..8; peerName is 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 spelled free, never a zero stake. It surfaces as ArcadeBridgeValue.matchmake: the agreement for this session, or null when the player did not come through the lobby (and always null standalone). routeShellMessage gains an optional onMatchmake; absent means drop in silence. 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.
  2. The banner acts on its own — there is no button to press (since SDK 0.15.0). It puts the transaction in front of the wallet as soon as the agreement can be acted on, and the wallet prompt is the consent — nothing is ever sent without one. Per role: a free creator's table opens at the agreed seat count with no press; a joiner's join goes out the moment the agreed peer's table appears; a paid creator is asked to approve WCHI first when their allowance is short, then to stake.
  3. At most one automatic prompt 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 over Try again.
  4. 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.
  5. 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.
  6. Embedded, your manual create card is demoted (0.14.2). 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. Since 0.17.0 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.
  7. The 90-second staleness line (0.14.2) 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 wait is the chain's own record of the exact table being watched (0.17.0): 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, at a stake, walk the player away from money they had just escrowed (sdk/src/components/ChannelLobby.tsx, the watched-table effect).
  8. Embedded, a live agreement gets the whole screen to itself (0.17.0). 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). Raw channel ids left the lobby with it. 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.
  9. What you must do about all of this: nothing, but rebuild. 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.
  10. One API note (0.15.0, mildly breaking): approve(), create() and join() on useArcadeWager now resolve the tx hash they submitted (or null). Consumers annotating those three as Promise<void> need the type widened; call sites that ignore the result are unaffected.

Step 4 — the loop, split honestly

There is no local chain. Anything that needs a channel to be opened, adjudicated, relayed or closed needs the hosted playground, which needs the network. That is the price of not asking you to run seven containers, and it is paid knowingly. Everything below the channel line is free, fast, and offline.

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. The template's e2e/ also carries a live-deployment slice — the on-chain scenario runner, its wager support and the relay match driver, and the e2e:smoke, e2e:bare, e2e:parsers, onchain and e2e:relay scripts that import them — which needs a chain, a host GSP with your blob registered, a relay and funded names: an operator's deployment, not a builder's machine. The template's e2e/README.md lists those files; a fork that runs no deployment deletes them, the scripts beside them and the CI steps that call them, and the offline ladder stays green.

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.

On the hosted playground — test-arcade.xaya.io:

npm run dev:playground

That is a committed script in the template's package.json, carrying five values inline — three proxy destinations plus two NEXT_PUBLIC_*:

WS_PROXY_DESTINATION=https://test-arcade.xaya.io/relay
CHAIN_PROXY_DESTINATION=https://test-arcade.xaya.io/chain
GSP_PROXY_DESTINATION=https://test-arcade.xaya.io/gsp
NEXT_PUBLIC_POLYGON_RPC=/chain      # without it, wagmi reads REAL Polygon while you
                                    # believe you are on the fork — the /chain rewrite
                                    # sits there unused
NEXT_PUBLIC_DEV_WALLET=1            # dev wallets need BOTH this flag and a non-empty
                                    # NEXT_PUBLIC_POLYGON_RPC, or you get no signable
                                    # identity at all

It is an inline script prefix and not a .env.local, deliberately: Next auto-loads .env.local for next build as well as next dev, so a dotfile would bake dev-only values into a bundle destined for the real arcade — a game that works on the playground and is broken in production.

The recognition test, because it is how you know the five values 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.

Then dry-run the real pipeline. 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: the playground serves xarc and arcade.xaya.io serves arcbeta, and a curl of that JSON is the answer for whichever plane you are building for. For the dry run:

NEXT_PUBLIC_GAME_ID=xarc 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. As of SDK 0.16.0 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. A build on 0.15.5 or older emits the older shape: one argument, no frame-ancestors at all when you pass nothing, and a bare wss:/ws: in connect-src that lets any cross-origin relay through — so after the SDK bump re-read the headers your deployment actually serves rather than assuming the old build's behaviour carried over.

Upload the resulting dist/bundle.tar.gz and blob/rules.wasm (with their .sha256 sidecars) at https://test-arcade.xaya.io/attach. This is the one door a builder uploads through today/attach is the playground's own upload front, served as a static page beside the shell, and it posts the same multipart contract to the same /api/submissions endpoint a real submission travels through, so nothing about the artifact changes. The real arcade's /submit is closed and says so on the page (§1, §3 step 6). The playground auto-accepts: your upload runs the real pre-flight, the real bake and a real on-chain registration on a disposable fork, and goes live without a moderator.

Beyond the four artifacts, the attach form asks for the four registration values — slug, gameType, seats and cfg suffix — plus your repository URL. That URL is a label. Pre-flight checks that it is https:// and nothing more (preflight.ts); the accept pipeline records it as the listing's repository link (accept.ts) and nothing ever fetches it, so an attach never waits on the source being pushed anywhere — publishing the repository is a separate task, for the hand-over in step 6. The listing copy that a real registration also carries — title, tagline, description, how-it-works, tags — is filled in for you here with placeholders, and those placeholders are what this plane's card and detail page then show, so your own listing copy is never sent and never checked here — §3 step 6 carries its bounds, and they are enforced when your game is registered for real.

  • The 201 is the upload, not the go-live. Poll the receipt's statusUrl until status is accepted or a step reports failed — typically well under a minute — and only then open the game; a single sleep followed by a page load reports whatever was true at that instant.
  • Keep the resubmitToken the response returns, beside the receipt's id and statusUrl (mintResubmitToken in handler.ts; the 201 receipt carries it with slug, gameType, bundleSha256 and rulesSha256), and keep it out of the repository and out of anything you share — it is the credential that replaces your game on that plane; you send it back as a multipart field of the same name on your next upload (§3 step 6 has the invocation). It is the plane's only proof that the next upload of that (slug, gameType) pair is you, and without it the three collision checks stay hard errors and your fix-and-resubmit loop dies on iteration one. There is no recovery: losing it costs a wipe. It exists only where AUTO_ACCEPT is on, and it exempts exactly three collisions — the reserved slugs, the size caps, the wasm ABI walk, the sha verification and the card check are unchanged, which is the entire point of a dry run (preflight.ts).
  • Play it at https://test-arcade.xaya.io/play/<slug> — an attached game gets a shell content row on this plane, so it lands as a card on /games and gets the same /play/<slug> page production serves, with your bundle framed inside the real shell. 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's src rather than remembering it: it is a per-plane operator setting, it may carry your slug in the hostname, and on a first-party plane it is the shell's own 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 localStorage key, 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.tsselectedSlot / selectedDevWallet / selectDevWallet, keyed off storageKey(), which is your storagePrefix). Each slot owns its own p/ names, so every seat is a distinct named identity. Every visitor starts on slot 1selectedSlot() 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 test wallets are shared with every other builder. If a transaction inexplicably fails, another builder is probably on your slot: switch slots and reload.

"My game vanished." The playground is disposable and wipes on a weekly clock — 04:10 UTC every Monday (a scheduled operator reset; the same cadence is stated publicly at https://arcade.xaya.io/docs/requirements), plus whenever else the plane needs rebuilding; a cold fork takes a while to bootstrap after one. Everything you attached goes with it, by design. There is no status or epoch endpoint to ask — you diagnose it from the symptom set, which is the trap, because mid-wipe the symptoms are exactly what a bug in your own game looks like: your game is gone from GET /api/games and off /games, /play/<slug> and /g/<slug>/arcade-config.json 404, your dev wallet has no balance and owns no p/ name, your open channel is gone. All of them at once means the plane, not your code. Nothing in your repo is lost — re-upload the same bundle and it auto-accepts again. If the site itself is unreachable or erroring, the plane is coming back up; wait and retry before you debug anything.

What the playground does NOT prove

This is the single not-proven list. There is no second one.

  1. Not Polygon. No real gas, no real WCHI, no real name prices, no mainnet congestion, no reorg behaviour. Above all: a name or gameType that is free on the playground may be taken forever on mainnet. Nothing answers that in advance — the real upload's pre-flight is the first and only check (step 0).
  2. Not uniqueness. On the playground your resubmitToken lets your own slug and gameType be re-uploaded, and the GSP's "already registered" error is a warning. The real door does neither. A collision with another builder is still refused here — but only on this plane, which says nothing about the real registry.
  3. Not the arcade's own shelf. Your attached game is in this plane's shell — a card on /games, 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 this plane is listed, ranked, reserved or discoverable on arcade.xaya.io, and your row goes with the weekly wipe. The mechanism is worth knowing, because it is the same one on both planes: the shell's catalogue, its /play/<slug> page and the lobby's per-game entry all hang off a content row, which the accept pipeline writes only when the plane it is accepting into names a shell it can write to (arcade-platform/submissions/src/accept.ts, step 7's SITE_INTERNAL guard); a plane that names none skips the step, and the accepted game's play URL stays /g/<slug>/ rather than /play/<slug> (accept.ts, playUrlFor). Both public planes run a shell, so both write the row — but that is a property of the plane, and a private deployment configured without one gives your game no /play page, no catalogue tile and no lobby entry.
  4. Not the agreed-match handoff — for anybody, on this 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 ready gate in xaya-arcade/src/components/lobby/LobbyClient.tsx, "an unreachable lobby must also be an unclickable one"), and this plane runs no chat homeserver. So no match is ever agreed here, no arcade:matchmake is ever delivered to any bundle, and the banner that acts on one never arms. Your attached 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 an attached row never carries one: the loader strips it so a submitted 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 attached game is not listed there either — you reach your game by its URL. Your onMatchmake handling is first exercised on the real arcade.
  5. 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 / MetaMask / WalletConnect 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.
  6. Not wagering — for your game. Wagering is live on this plane and the first-party games take stakes on it, so you will see it working next to yours. But the accept path only ever carries forward a wager pair that was already set on that row and never sets one (arcade-platform/submissions/src/accept.ts), so your game arrives free-play-only with caps.wager false. Enabling it is an operator action on the real arcade, not something a dry run exercises. See this skill's WAGERING.md.
  7. Not the games-host or GSP version. The playground runs its own build of the host GSP, games-host and submissions service, and those can lag the live ones. The arcade:* wire is frozen and additive-only so this is bounded, but a platform-side behaviour change lands here only when the playground images are rebuilt.
  8. Not the review boundary. Whether a game gets an origin of its own or is served from the shell's is a per-plane decision (§5), and where it is the shell's the browser boundary between a bundle and the shell's realm is gone by design — so the human read of your repo is the whole gate, which is why the real arcade still takes no self-serve uploads today. The playground auto-accepts with no human read at all, so it demonstrates nothing about that gate and fixes nothing about it either. Read it as the opposite of evidence on this point.
  9. 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 playground cannot see.
  10. Not latency, and not a phone. A local fork on an adjacent host. Real-time feel over a real network, on mobile radio, is still unproven — and the playground 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.
  11. 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 — pass the real pre-flight, survive the real bake, register into a real GSP, 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, because an attached game gets a shell row here.

And it worked on the playground reserves NOTHING. gameType is globally unique on the real chain and burned forever on first real registration. A game that works perfectly here can still lose its name to someone else between your dry run and your real submission — and there is no probe to warn you, so the first you hear of it is a pre-flight error on the real upload. Have a second gameType in mind before you submit; re-identifying is the closed eight-file 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 uploads fine, passes pre-flight, gets accepted — 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 (next.config.ts). tests/repo/bundle-pack.test.ts runs that packer on two equal-byte trees with different modes and requires one hash; keep it.

UPLOAD THE BLOB YOUR GREEN RUN USED. Immediately before uploading, re-run bash blob/check-blob.sh, and check the two shas against what you are about to upload. Nothing automates this comparison — you do it by eye, which is exactly why it is written down here. The browser loads the registered blob, not the one in your working tree: a bundle built against a different blob boots, joins a channel, and then renders garbage — because the client's in-browser judge and the GSP's referee are running different rules. sha256sum dist/bundle.tar.gz blob/rules.wasm and compare against the bundleSha256 / rulesSha256 the upload response echoed back — the 201 receipt and GET /api/submissions/<id> (the receipt's own statusUrl) both carry them (handler.ts), and no page renders them: the playground's attach page displays no hash, so capture them from the JSON. If they differ you are handing over bytes you have never dry-run.

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 actually played (§3 step 4, §6); and the slug is the one you dry-ran. 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 the attach page 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 — get your game listed

There is no upload door on the real arcade today, so this step is a hand-over rather than a POST. arcade.xaya.io takes no uploads during the curated phase and says so where you would look for the form: /submit serves a closed page, /build's Publishing section points at the playground, and every /api/submissions route answers 503 (§1). The two doors that ARE open are the ones §1 names:

  1. Attach the artifacts on the playground (§3 step 4) and play a full match at every seat count you mean to declare. That plane runs the same pre-flight and the same bake, so it is where every artifact bound below is actually enforced against your bytes, and where a reserved slug or a badly shaped cfgSuffix comes back in seconds.
  2. Raise the game in #builders on Discord with your repository, your four registration values, the listing copy below, and the two sha256s the playground echoed back. The operator takes it from there.

Read the rest of this section anyway — a closed door changes nothing about what your artifacts have to be. One pre-flight applies every bound below, and the playground's attach page runs exactly that pre-flight, so it is where you find out whether your bytes clear it. The four registration values and the listing copy are what you write down and hand over in step 2; the artifact bounds are what your bytes must already satisfy before anybody can list them.

The artifacts travel as a multipart POST to /api/submissions — that is what the playground's attach page posts for you, and what an agent-driven upload posts directly — with these part names (runUpload in arcade-platform/submissions/src/handler.ts):

partkindrequiredcontent
rulesfileyesblob/rules.wasm
rulesShafileyesthe .sha256 sidecar file (a bare digest or the sha256sum line inside)
bundlefileyesdist/bundle.tar.gz
bundleShafileyesits .sha256 sidecar file
cardfilenocard image, PNG/JPEG/WEBP; a zero-byte part is treated as absent
metatext fieldyesone JSON string, parsed with JSON.parse
resubmitTokentext fieldplayground re-uploads onlythe token your previous upload returned (§3 step 4)

The four artifacts are file parts, the two sidecars included. A part counts as a file only when it carries a filename, so a text rulesSha=<hex> is refused with missing required file field(s): rulesSha even when the digest is right. meta is the opposite — a text field — and is best read from a file rather than written inline, because a ; inside an inline -F meta={...} ends the value at that character and the server answers meta is not valid JSON. The whole invocation, against the playground:

curl -sS -X POST https://test-arcade.xaya.io/api/submissions \
  -F "rules=@blob/rules.wasm" \
  -F "rulesSha=@blob/rules.wasm.sha256" \
  -F "bundle=@dist/bundle.tar.gz" \
  -F "bundleSha=@dist/bundle.tar.gz.sha256" \
  -F "meta=<meta.json" \
  -F "resubmitToken=$RESUBMIT_TOKEN"        # re-uploads of a slug you already claimed there

--form-string "meta=$META" posts the same text field from a shell variable, with no @ or < interpretation. The 201 receipt carries id, statusUrl (/api/submissions/<id>, the same path the status route answers on), status, slug, gameType, bundleSha256, rulesSha256 and the pre-flight warnings — plus autoAccept and resubmitToken on the playground. The id is the entire handle on your row: nothing looks a submission up by slug. bundleSha256 is the hash of the bytes you uploaded, and it is not the hash the shelf shows. Accept rewrites /__arcade_base__ to /g/<slug> inside the bundle and registers the result, so the sha256 the games list carries for your slug (/api/games, and the games-host manifest) is the baked bundle's — different by design, not a mismatch (bakeBundle in games-host/src/bake.ts; the uploaded hash stays on the submission as provenance, accept.ts). rulesSha256 is the same on both sides: the blob is registered byte for byte.

meta is where the four registration values live, alongside the listing copy. This is its full shape — every property below is validated by validateMeta (preflight.ts), and the values here are placeholders, not values to copy:

{
  "slug": "your-slug",
  "gameType": "yourkey",
  "title": "Your Game",
  "seats": { "min": 2, "max": 4 },
  "cfgSuffix": "0011aabb",
  "repoUrl": "https://github.com/you/your-game",
  "repoPrivate": false,
  "tagline": "One line that sells it.",
  "description": "What the game is.",
  "howItWorks": "How a match plays out.",
  "tags": ["arcade", "realtime"],
  "credit": null
}

Every string above is a placeholder"0011aabb" in particular is not anyone's real suffix, and yours comes from your own blob/MANIFEST.md (§3 step 2); a blob that takes no cfg bytes sends "cfgSuffix": null in that position — the property present, the value null, never "". repoPrivate and credit are the only optional properties; cfgSuffix must be present even when it is null, because an omitted suffix on a blob that expects one wedges every channel part-filled, so the validator makes omission an error rather than a silent empty suffix (validateMeta). The playground's attach form asks you for the four registration values and a repository URL, and fills the listing copy with placeholders of its own, so the copy below is checked for the first time when your game is registered for real — write it before the hand-over, not during it.

The bounds, each with its authority:

  • Artifacts. rules.wasm8 MiB (RAW_WASM_MAX_BYTES, preflight.ts), bundle.tar.gz64 MiB (BUNDLE_MAX_BYTES, preflight.ts), card image ≤ 2 MiB (CARD_IMAGE_MAX_BYTES, preflight.ts). No image dimension is enforced anywhere — pre-flight caps the card's bytes and checks its magic bytes for PNG/JPEG/WEBP (detectCardImage, preflight.ts), and nothing else. Without a card your game gets a plain tile.
  • Identity. slug — your site URL — must match ^[a-z0-9-]+$ and be at most 32 chars (SLUG_RE/SLUG_MAX, games-host/src/bake.ts, re-exported by preflight.ts so the upload gate can never be looser than the bake). gameType must match ^[a-z0-9_-]{1,32}$ (GAME_TYPE_RE, preflight.ts) — it is the g/ registry key = your GAME_KEY, permanent once accepted. title60 chars (TITLE_MAX, preflight.ts), plus the seat range. The chain clamps seats to 2..4 (MAX_SEATS, preflight.ts, mirroring MAX_BOARD_PLAYERS); see §6 — that ceiling is the platform's, not a statement about your game.
  • cfg suffix. An explicit none-or-hex choice: null, or lowercase hex of even length and at most 128 chars (CFG_SUFFIX_MAX, preflight.ts). Copy it from your blob/MANIFEST.md (§3 step 2).
  • Listing copy. Tagline ≤ 120 (TAGLINE_MAX, preflight.ts), description ≤ 2000 (DESCRIPTION_MAX, preflight.ts), how-it-works ≤ 2000 (HOW_IT_WORKS_MAX, preflight.ts), at most 6 tags (TAGS_MAX, preflight.ts) of ≤ 24 chars each (TAG_MAX, preflight.ts), optional credit ≤ 120 (CREDIT_MAX, preflight.ts).
  • Repo URL, which must be https:// (preflight.ts), plus a repoPrivate boolean (preflight.ts). Someone reads the source before your game goes on the shelf. Say so if the repo is private and grant access; do not quietly hand over an unreadable repo.

The gates you will actually hit:

  • The reserved first-party slugs are refused outright (RESERVED_SLUGS, preflight.ts — the live shelf's own games; a collision there would let a submission masquerade as one). That list is in the source and nowhere else; read it there before you pick a slug, because it grows every time a first-party game ships and a count copied out of any document is already wrong.
  • A full queue answers 429 with "the submission queue is full — try again later", checked before your upload is buffered (handler.ts). The cap is the service's MAX_PENDING, an operator env setting (arcade-platform/submissions/src/server.ts) — so this file states no number for it. Back off and retry; do not re-upload in a loop.
  • Cross-pending refusal. Another queued upload's slug or gameType blocks yours, whether or not the GSP is reachable (preflight.ts's reservedGameTypes) — two queued rows must never race for a name whose on-chain burn is irreversible.
  • Base-path check. A bundle whose root index.html carries no /__arcade_base__ reference is rejected at upload (preflight.ts), and so is one whose script chunk mentioning arcade-config.json does not also carry the __arcade_base__ token (checkBundleConfigChunk) — that shape means a pre-0.5.2 SDK that fetches its runtime config from the origin root and can never find it under /g/<slug>/. Building with the template's scripts/build-export.sh --bundle satisfies both. See §5.
  • The wasm ABI gate, parsed structurally and never executed; sidecar sha mismatches; size caps; slug/gameType format; the seat clamp.

What listing your game does — the same pipeline, whoever presses the button. An Accept runs re-preflight → hosting → archive sanity → on-chain registration verified against the GSP → bundle registration → site listing + card → outside-in verify, with a step log the operator watches. A failed step is visible and re-pressable. Live means /games and /play/<slug> serve immediately — no site rebuild, which is why listing a game is seconds of work once the hand-over is done.

Behind the scenes, and how it will work when submissions open. The service the artifacts reach is a queue, not a single button, and none of this is a door a builder can walk through today: an upload lands as pending, a human reviews it, and Accept runs the pipeline above. A clean upload returns its id and statusUrl, which are the entire recovery surface — no login, no listing, no lookup by slug, so losing the id loses the row while the queue keeps holding the names. rejected carries a reason and releases them. On the playground none of that queue is in your way (AUTO_ACCEPT is on: pre-flight, then straight into the pipeline), and on the real arcade the operator holds it. Written down here so the receipts and error codes above are readable, not as a route to follow.

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 touchControls and the SDK puts a pad and buttons on the screen, a tap-native one declares fullFrameOnTouch and 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.ts and 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 see WASM.md §A4 for the trace half. The live-deployment slice under e2e/ (§3 step 4) is not part of that port: delete it unless you operate a deployment.
  • 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 you contact the operator; 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 twelve arcade:* 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) are frozen and additive-only; wager-request, wager-result and caps were appended, and matchmake was appended in SDK 0.14.0 (§3 step 3a). New messages may only ever be appended. One exception, and it is a real break: SDK 0.8.0 promoted move-request.signerName from optional to REQUIRED — an unbound request is never signed. A bundle built against an SDK older than 0.8.0 must be rebuilt. The current shell ANSWERS such a request with an error move-result naming the problem (and the SDK's own MoveBroker/BrokeredWriter throw before posting, as of 0.9.0); only a pre-0.9.0 shell dropped it silently, where the symptom was a hang with nothing logged on either side. Registered game ids are frozen the same way.

    The wager op list is appended to under the same rule, and 0.16.0 appended one. WAGER_OPS gained 'evictSeat' (carrying matchIdx and a seat integer) 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. Appended, so no existing op moved. The consequence for a bundle: the shell must be on 0.16 before a game is re-vendored against it, because a shell that predates the op drops the request in its parser and the game would be offering a button whose only outcome is a timeout. WagerBroker bounds that one op at 20 s ("the arcade did not answer the eviction request") so an old shell costs a message rather than a wedged client; every other wager op still waits indefinitely, because it is a wallet prompt a player may take minutes over.

  • gameId vs moveNamespace. 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/sdk and arrives as vendor/xayaarcade-sdk-<ver>.tgz. This file states no version — the version is whatever arcade-platform/sdk/package.json says, 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, 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.

Two shapes of games origin, and the arcade runs the second one today. By default the games origin is deliberately not the shell's: games-host takes it as required configuration in path mode and refuses to start when it resolves to the same origin as the shell's, so the two cannot be collapsed back together by an ordinary config edit (GAMES_PUBLIC_ORIGIN, games-host/src/server.ts — it may carry a {slug} placeholder, which gives every game a host of its own). That refusal has exactly one deliberate override: GAMES_SAME_ORIGIN_FIRST_PARTY=1, the operator declaring that every bundle this deployment hosts is their own first-party code, which lets the games be path-served from the shell's own origin. The same variable, with the same exact value, is what the shell honours for the manifest rows it will frame (games-host/src/server.ts, xaya-arcade/src/lib/registry/hosted-games.ts). The arcade is on that setting: your game is served at /g/<slug>/ on arcade.xaya.io itself, the same origin the shell is on. Read the origin off the /play/<slug> iframe's src rather than assuming either shape — it is per plane, and it can change.

What the same-origin shape gives up, and it is not subtle. An origin is the browser's real boundary around builder-supplied code: everything an origin owns — localStorage, cookies, the embedding page's DOM — is reachable from inside the frame once the two share one, and an iframe sandbox grants nothing back (same-origin script reaches straight into the parent). So on a first-party plane a hosted bundle can reach the shell's realm, which is precisely why the setting is only ever safe where no third-party bundle is hosted, and why the human read of your repo is the gate that matters (§1). Two consequences you own as a builder: never write anything into localStorage under a prefix that is not yours (below), and never assume the browser is keeping your bundle honest — it may not be.

The served field set is gspUrl, relayUrl, chainRpcUrl, arcadeOrigin, gameId and devWallet, 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). 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 on a first-party plane 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, and it now carries more weight than it used to. Where the games get an origin of their own, the browser isolates your store from the shell's: your localStorage — the channel session keys the SDK generates and keeps there included — is unreachable from the shell's origin, and the shell's own keys are not in yours. On a first-party plane, where the games are served from the shell's origin, there is no such separation — your keys and the shell's sit in one store, and a prefix collision is no longer only a game-versus-game problem. And an origin never separated game from game anyway: a plane may serve every game from one games origin rather than a host per slug, and there two games with the same storagePrefix write over each other. 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, LE uint16 per participant, written into a 1024-byte buffer (ARCADE-ABI.md §5, ShareWeights in wasm_judge.cpp). The host rejects any length that is not exactly 2 × 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.
  • 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 — four of the five first-party games do (all but xayaships, which is 2-seat and 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 (2..4) all export arcade_share_weights; Xayaships and Vector Sumo do not. 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 is winner >= 0 ? UpdateStats() : SettleDraw(). Note what follows: the registry's drawPolicy is honoured only on that fallback draw — a game's own valid weights always supersede it. The validators are DecodeShareWeights and WeightsValid in engine/gsp/wagering.{cpp,hpp}, 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 SettleDrawdrawPolicy → 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 placementsarcade-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 rank r of k gets (k-r) parts of k(k+1)/2, remainder to rank 0. Zero alive returns false and lets the host's draw-refund run.
  • Deliberate winner-take-all at four seatsarcade-xayatrails/rules/arcade_core.cpp. Winner 10000, everyone else 0; a draw or an undecided terminal returns false. It exports the surface only to keep the payout path uniform, not because it splits anything. This is the "I chose winner-take-all on purpose" example — copy this one if that is your answer, rather than omitting the export and leaving it to chance.
  • Verdict-aware splitarcade-dungeonchannel/rules/dungeon_core.cpp. A single winner takes 10000; 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.cppUpdateMetadataReinit + 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.

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? Two steps, and neither is an upload form here: attach it on https://test-arcade.xaya.io/attach and play it, then ask in #builders on Discord to have it listed. The arcade takes no self-serve uploads during the curated phase and its own /submit and /build pages send you down exactly that route (§1, §3 step 6).
  • How long until my game is live? On the playground, poll the receipt's statusUrl until accepted or a failed step — pre-flight is instant, it auto-accepts, and the pipeline behind it typically finishes well under a minute. On the real arcade it is however long the hand-over takes: a human reads your repo, and the go-live pipeline behind the operator's Accept is seconds once they do.
  • How do I update my shipped game? Not yourself — pre-flight refuses the collision, and there is no upload door open to you anyway. Contact the operator for a bundle swap. A self-serve update path belongs to Arcade v2 (§1); there is none today.
  • 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 the localStorage key <storagePrefix>_debug to '1' and reload (the prefix is the one you passed to configureApp — e.g. prefix trailz → key trailz_debug). debugEnabled / dlog / dwarn are 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 at upload, in the form or in meta (§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.squareBoard hands 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:matchmake and 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 is free / 1 / 10 WCHI with human-scale dispute windows; see this skill's WAGERING.md §1.
  • My game has secrets / simultaneous moves? HIDDEN-INFORMATION.md and COMMIT-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/game/game-shell.ts} and arcade-vector-sumo/{blob/MANIFEST.md, src/lib/vector-sumo/sha256.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.