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 a 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, with the committed .wasm scanned as raw bytes and 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 bundle a playground serves 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 slice: e2e/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. A 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.

Optional performance measurements

When tuning a game, you can benchmark typical and worst-case workloads on its rules blob. These measurements are optional, not a submission requirement or a consensus rule. If you share timings, distinguish:

  • Browser: proof construction, signing and compression time, identifying whether wallet interaction is included.
  • Native GSP: complete dispute/resolution proof verification time, including decoding, signature checks and replaying the game transitions. Measure on representative server hardware using the deployment's pinned runtime.
  • Rules calls: fuel consumed and execution time for the heaviest individual metered exports. The fuel limit applies per call, not to an entire proof.

Useful workloads include long matches, maximum supported player counts, large valid states and malformed inputs. Also measure the complete transaction after proof encoding, including base64, JSON and contract-call overhead. The state output limit does not guarantee a dispute proof fits a transaction: proofs can contain multiple states, moves and signatures, and some data compresses poorly. To make results comparable, note the blob hash, GSP image/runtime, CPU, proof size and transition/signature counts alongside repeated timings (median, p95 and maximum). Separate cold compilation/startup from warmed execution; retaining fixtures and the command makes a result reproducible.

A browser result such as "5 ms per proof" does not establish GSP capacity. Fuel is not elapsed time, and wall-clock measurements must never decide consensus acceptance. Measure headroom under the deployed fuel cap; all participants and GSPs must use the same protocol limits.

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.

Integration tests on a playground

The operator runs integration checks during review. You can run them yourself first on the public playground; it is not required for submission.

Channel tests need a registered rules blob and a running referee, relay and chain.

The public playground. test-arcade.xaya.io is a disposable fork with pre-funded, pre-named test wallets. Build the bundle for its move namespace (NEXT_PUBLIC_GAME_ID=xarc bash scripts/build-export.sh --bundle), then open its attach page: enter the slug, your game type (your code's GAME_KEY), the seat range and cfg suffix from blob/MANIFEST.md and your repository's https URL, and upload blob/rules.wasm, dist/bundle.tar.gz and their .sha256 files. The upload runs the platform's submission pre-flight, the bake and a real registration, and goes live with no review. It skips the registrar's opening-state check, so a wrong seat range or cfg suffix shows up as a channel that fills and never starts. Attached games are free-play only. To ship a fix, attach again from the same browser: the page keeps the token that proves the slug and game type are yours. The chain is wiped without warning, and nothing there reserves a name on the arcade.

Two halves, and you want both.

1: Run the real pipeline. Attached, your game is registered on the plane's chain and live at /g/<slug>/ and /play/<slug>, having passed the production bake and the upload pre-flight. What a submission carries is in Submitting. For iterating on the UI against that plane, the template ships npm run dev:playground: point it at the plane with PLAYGROUND_ORIGIN (https://test-arcade.xaya.io for the public one).

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. A game on a plane has two URLs: /play/<slug> (that plane's shell around it) and /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 1: selectedSlot() 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 by everyone on that plane, 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. On the public playground, attach it 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 attach wrote.

What that rung proves: these exact bytes, with the tested seat counts, cfgSuffix and arcade 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 a 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. A gameType that is free on your fork can be taken forever on mainnet. Nothing reserves one in advance and there is no probe to ask: the review'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. Your playground runs the arcade shell in playground chrome (a catalogue, a lobby, an account page, a banner saying where you are) and a registered 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 the real arcade's wagering. A game attached to the public playground is free-play only. Wager enablement on the real arcade stays a separate operator action after acceptance, and the paid flow there is first exercised then.
  6. Not latency, and not a phone. A fork on an adjacent host is not a real network, and a 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 submit: declaring touch play is a requirement, not a polish item: see The SDK. The label your card carries on a playground is what the arcade will tell your players.

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