Playground — disposable chain, test WCHI, wiped without warning
Builder docs

Testing

The determinism ladder, the off-chain channel scenarios, and the blob checks.

A game's whole promise is determinism: the same blob, the same result, everywhere. The template ships a ladder of gates that prove it. Every rung but the last runs offline — no chain, no relay, no funds, no wallet, no network. The last rung is the only one that opens a real channel, and it runs on the disposable playground. Keep every rung.

The offline ladder

CommandWhat it buys you
npm testThe vitest suites, including the copyright guard — one case per tracked file (tests/repo/copyright-guard.test.ts iterates git ls-files), checking each file's path and its content — the committed .wasm scanned as raw bytes, the vendored SDK tarball decompressed and scanned too.
bash blob/tests/run-tests.shThe C++ battery in the pinned container — the targets its own header lists (the template's: engine, golden, share-weights, rotation, board, packed-core, blob-fresh, packed-determinism (native == wasmtime on the real blob, both cfg vectors) and fuel-probe (measured worst case against the consensus cap)). Needs Docker, and the network once, for the image build.
npm run e2eThe channel suite — 12 scenarios, the SCENARIOS array in e2e/suite.ts: full game, powerups, recovery, divergence, dispute resolution, forfeit, abort, dropped-out timeout, draw close, resolution retry, reconnect, out-of-order proofs. Real ChannelManagers over an in-memory bus.
npm run e2e:determinismThe V8 leg: the real blob replayed in the browser's runtime against the committed golden traces → V8 matches the golden traces (e2e/packed-determinism.ts).
npm run selfplayAI-vs-AI at scale — 100 games by default (e2e/selfplay.ts); every game must hold byte-identical lockstep at each tick and close its channel. Flags: --games=N --max-moves=M --seed=S --mode=… --verify-determinism (the last runs each seed twice and asserts the same winner and the same final state hash). The slow rung (15+ min, single-threaded) — the rest of the ladder is seconds.
bash blob/check-blob.shStructural gate on the committed blob, no Docker: the five assertions its header lists — zero imports, the consensus export set (missing one fails; the ABI-optional arcade_share_weights / arcade_ejected_mask only produce a NOTE naming the host's fallback), no .debug_* sections, the toolchain fingerprint, and sha256 against the sidecar. Exports outside the known set only WARN — pass --strict to make them fatal, which is what CI does. --rebuild additionally rebuilds from a clean tree in the pinned container and asserts the bytes.
npm run verify:cssThat the SDK's Tailwind classes actually reached the built stylesheet — a failure no unit test can see, because it exists only in the emitted CSS. Run npm run build first: it greps .next/static and exits 1 if it finds no CSS there (scripts/check-sdk-css.mjs).

npm run dev is not a rung. It renders your board with no chain, no wallet and no second player, and it cannot open a channel. It also serves at / with no base path — the /__arcade_base__ placeholder is baked only by build-export.sh --bundle (next.config.ts) — so a hardcoded root-absolute URL (fetch('/data.json'), <img src="/sprite.png">, url(/bg.png)) looks perfectly fine in both dev servers and breaks the moment the platform serves your build under /g/<slug>/. Nothing offline catches that; the uploaded playground build below is the first rung that does.

Narrower offline scripts in the same package.json: npm run guard (just the copyright slice of npm test), npm run e2e:imports and npm run typecheck. Everything above is the part of the ladder a builder proves alone. The template's e2e/ also carries a live-deployment slicee2e/onchain-scenarios.ts with its wager and relay companions, and the e2e:smoke, e2e:bare, e2e:parsers, onchain and e2e:relay scripts that import them — which needs a forked chain, xayax, a host GSP with your blob registered, a relay and a funded key: an operator's deployment, which you are not expected to have. 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. The playground below is the builder's version of that rung.

The three determinism legs

This is the gate that matters most. The same rules must judge a state identically in:

  • native C++ (blob/tests/run-tests.sh),
  • wasmtime — the host GSP's judge (the same script's packed-determinism target),
  • V8 — the browser's judge (npm run e2e:determinism).

When all three agree, native == wasmtime == V8, a state a player signs in their browser is a state the chain will adjudicate the same way. A blob that diverges across runtimes is a consensus hazard, not a bug you can paper over.

Reproducibility

bash blob/check-blob.sh --rebuild rebuilds the blob from a clean tree (a git archive, not your working tree) in the pinned container and asserts the bytes hash to the committed .sha256. This is what backs the promise that anyone can distrust you and re-derive the exact bytes the chain runs. See The rules blob for the build.

The last rung — a real channel, on the playground

There is no local chain. Anything that needs a channel opened, adjudicated, relayed or closed needs the hosted playground at test-arcade.xaya.io — a disposable public test plane: its own forked chain, auto-accepting submissions, pre-funded and pre-named test wallets, wiped every Monday around 04:10 UTC and without notice in between, and it reserves nothing. What makes it a gate rather than a demo is that it runs the same pre-flight, the same bake, the same games-host and the same accept pipeline as production.

Two halves, and you want both.

1 — Dry-run the real pipeline. Build the bundle for the arcade's move namespace, upload the four artifacts at test-arcade.xaya.io/attach, and it auto-accepts: your game is live at /g/<slug>/ in seconds, having passed the production pre-flight and bake. Keep the resubmitToken the response returns — presenting it on your next upload is the only thing that lets the same (slug, gameType) through again after a fix (submissions/src/handler.ts, preflight.ts), and it exempts exactly three collision checks and nothing else. The step-by-step is in the quickstart; the form's fields are in Submitting. For iterating on the UI against that plane, the template ships npm run dev:playground.

2 — Play a full match at every seat count you intend to declare, from N isolated browser profiles on N different dev-wallet slots. Be exact:

  • The dev-wallet slot is one localStorage key, <yourStoragePrefix>_dev_wallet (arcade-platform/sdk/src/lib/chain/dev-wallet.ts, storageKey()). Two tabs — or two windows — of one profile share it, so they are the same player. The playground's game list gives each row two buttons — Open in the arcade (/play/<slug>, that plane's shell) and Bare mount (/g/<slug>/, the same bundle with none of it) — but they are two destinations, still one player: a second window.open from the same profile is the same wallet, session keys and channel proofs either way. N seats need N isolated storage contexts: N browser profiles, or N−1 plus one private window.
  • In each context, select a different slot and then reload. The selection only takes effect on the next page load — wagmi builds the mock connector's account order at module scope (selectDevWallet in that same file).
  • There are four slots (ALL_DEV_WALLETS in that file), matching the chain's four-seat ceiling (MAX_BOARD_PLAYERS = 4, arcade-platform/engine/gsp/wagering.hpp). Each slot is pre-named, so every seat is a distinct named identity.
  • Every visitor starts on slot 1selectedSlot() returns '1' whenever nothing is stored. Two people testing at the same moment, neither switching, are the same account racing each other's nonces. The wallets are shared with every other builder, so a transaction that fails for no reason usually means someone else is on your slot: switch and reload.

If everything vanishes mid-session — your game gone from the games list, /g/<slug>/arcade-config.json 404ing, your wallet unfunded and nameless, your open channel gone — that is a wipe, not your code, and those symptoms arrive together. Re-upload; it auto-accepts again. Either URL tells you: /g/<slug>/ is your game served bare, and /play/<slug> is the same bundle inside that plane's shell — a wipe takes both, because both hang off the registration the accept wrote.

What that rung proves, precisely: these exact bytes — this blob, at these seat counts, with this cfgSuffix, in a 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.

What the playground does not prove

  1. Not Polygon. No real gas, no real WCHI, no real name prices, no congestion, no reorgs.
  2. Not name uniqueness. Your resubmitToken re-claims your own slug and gameType here, and a gameType that is free on this fork can be taken forever on mainnet. Nothing reserves one in advance and there is no probe to ask: the real upload's pre-flight is the first and only check. Have a second gameType in mind before you submit.
  3. Not the arcade's own catalogue. The playground runs the arcade shell in playground chrome — a catalogue, a lobby, an account page, a banner saying where you are — and an attached game joins that catalogue: a card on /games, and /play/<slug> framing your bundle exactly as the real arcade frames an accepted one, so the iframe, the postMessage bridge, shell-brokered signing, the letterboxing and the CSP all are exercised for your game here. What is not the arcade's is the shelf itself — that plane shows only what its own games-host serves, and nothing you register there is listed, ranked or reserved on arcade.xaya.io.
  4. Not a real wallet. Dev-wallet signing is instant and never rejected. A UI that goes dead while a human approves a transaction, or that has no path for a rejected one, looks perfectly fine here.
  5. Not wagering, for your game. The plane runs its own ArcadeWager deploy, and the first-party games on its catalogue are wager-enabled against it — but an accepted submission registers with no wager pair, so yours is free play there and caps.wager is false. The paid flow is first exercised on the real arcade.
  6. Not latency, and not a phone. A fork on an adjacent host is not a real network, and the playground emulates nothing — whatever your adapter declares for touch, on-screen controls or fullFrameOnTouch, 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. How big a target actually feels under a thumb needs a real phone. Do both before you hand the game over: declaring touch play is a requirement, not a polish item — see The SDK. What the attach page tells you after an accept is what the arcade will tell your players.

Next: Submitting your game — the real door, and what a bundle is rejected for. The operator's side of an accept is Hosting & registration.