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

The SDK

Build your UI on @xayaarcade/sdk: the adapter, configureApp, debug logging, and vendoring.

@xayaarcade/sdk is the module your game's app code imports. It owns the channel protocol, the wallet, the lobby, the relay, and disputes. You provide a GameAdapter and a renderer; the SDK does the rest.

The package publishes four entry points, and the split is load-bearing rather than cosmetic — your game's shared code cannot use the main one:

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 runs in the browser bundle and under a bare npx tsx.
@xayaarcade/sdk/e2enode-only scenario infrastructure: on-chain moves, WsBroadcast, quietChannelLogs().
@xayaarcade/sdk/servernode-safe serving pieces, e.g. buildSecurityHeaders.

Shared code must take runtime values from /core, not from the main entry: the main entry statically imports connectkit, whose exports map has no default condition, so tsx dies with ERR_PACKAGE_PATH_NOT_EXPORTED and your whole scenario suite fails at import. Type-only imports are erased and are fine from anywhere. The template ships a gate for this — npm run e2e:imports fails the build if any file in your game lib, e2e/, src/game or src/app-identity.ts imports a runtime value from the main entry.

The SDK's version is whatever the committed vendor/ tarball ships. Read it off your package.json dependency line, and off sdk/package.json in a platform checkout, which is the authority vendor:sdk names the tarball after.

Configure the app first

Before rendering anything from the SDK, call configureApp once at bootstrap:

import { configureApp } from '@xayaarcade/sdk';
import { GAME_KEY, MOVE_NS, TITLE, STORAGE_PREFIX } from '@/app-identity';

configureApp({
  gameId: GAME_KEY,        // canonical wire id / registry key, e.g. 'xas'
  moveNamespace: MOVE_NS,  // on-chain `g/` move namespace — usually == gameId
  title: TITLE,            // display name in the shared shell screens
  storagePrefix: STORAGE_PREFIX, // prefix for every localStorage key the SDK owns
});

Every shared module (storage keys, the wasm loader's move namespace, the shell screens) reads this back through appConfig(). Resolution is lazy — importing the SDK never throws; only using an unconfigured value does. Calling configureApp again with an identical config is a harmless no-op; calling it with a different config throws (re-configuring mid-session would strand storage under the old prefix).

gameId and moveNamespace are usually the same value; a fork deployment may override the namespace. Both come from app-identity.ts — see Make it yours for why adapter.gameId must equal appConfig().moveNamespace.

The adapter

One GameAdapter plugs your game into the shell. Its fields:

FieldWhat it is
gameIdYour game's wire id (must equal appConfig().moveNamespace).
timingThe tick/turn timing your game runs on.
ensureLoadedLoads/verifies your rules blob before play.
makeBoardRulesBuilds the board-rules object the channel uses to judge states.
makeOpenChannelBuilds the open-channel handler.
RendererYour React board renderer.
useInputYour input hook.
controlsHintThe controls hint shown to players: one string, or { keyboard, touch } for a line per pointer (0.18.0; below).
stallGraceMsHow long to tolerate a stalled opponent before acting.
presentationHow your game asks to be shaped, including fullFrameOnTouch (0.11.0; below).
touchControlsOptional on-screen controls for touch devices (0.10.0; below).

Playable by touch — the requirement

Your game has to be playable on a phone, and it has to say which way it is. The arcade is browsed and played on phones; a game that only answers a keyboard is a game half the people who open it cannot play at all. There are exactly two ways to say it, and every game declares one of them:

DeclarationFor
touchControls on the adapterA keyboard game. The SDK mounts an on-screen d-pad and/or action buttons for you on a coarse pointer, and they synthesize the very key events your input hook already handles — see below.
presentation.fullFrameOnTouchA tap-native game, whose board already is the input surface. It asks for the whole frame to draw that surface in — see above.

They are not alternatives to pick by taste: a keyboard game declares the first, a tap-native game declares the second, and a game that declares neither hands a phone a keyboard that is not there. The five worked examples are the reference — Xayaman (d-pad + one action button), Xayatrails (d-pad, no buttons), Dungeon Channel and Vector Sumo (d-pad + three action buttons), Xayaships (no touchControls at all; fullFrameOnTouch: true).

Where the declaration goes after that — and why the adapter alone is not enough. The games-host stores the answer on the manifest row as a touch boolean, and it reads that answer from the bundle itself: a file named arcade-manifest.json at the archive root of bundle.tar.gz, containing {"touch": true} (bundleDeclaresTouch, 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 agrees with the adapter — keep the two in step. The bundle's own statement outranks every other source; the scan of a game's src/ applies only when an operator registers from a checkout, which an upload never is, and a bundle with no manifest leaves the row unanswered. A game whose row does not say yes is marked Desktop only (for now) on its card and its game page, and a player on a touch device cannot start or join a match in it at all: the lobby puts that reason where the button would be, with no way past it, because walking through a warning seats an opponent in a match that can only end in a forfeit. A phone that reaches the game some other way meets a warning line instead — by then the controls are inside the frame, where the arcade cannot stand in front of them. The playground's attach page says the same back to you the moment a game is accepted.

Test it before you hand the game over. Put the browser in device emulation at a phone width: that flips the coarse-pointer test, so you see the real layout and the real overlay. How big a target actually feels under a thumb needs a real phone — see Testing.

The presentation declaration

presentation is a plain, advisory declaration of how your game wants to be framed:

  • aspectRatio — e.g. 1 for a square board.
  • minViewport — the frame box below which the shell should letterbox rather than squash.
  • immersive'preferred' asks the shell to hide its own chrome when your game is open; it defaults to 'never'.
  • fullFrameOnTouch (0.11.0) — true takes the whole frame on a coarse-pointer device instead of being letterboxed to aspectRatio. It defaults to false, and a fine pointer is never affected. This is the opt-in for a tap-native game, whose board is its input surface: on a portrait phone the letterbox costs it twice, spending part of the display on gutter and shrinking every tap target on the board inside what is left. A game that declares touchControls already gets the full frame implicitly (its pad has to sit in the game's own gutter) and does not set this as well.

The frame you are given, and fitting it. The play page is one viewport tall and never scrolls: the arcade bar takes a row and 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; 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 (below) sizes the board to the largest square that fits both axes — the template fits by construction; keep those rules and size anything of your own from the box you measure.

Check it in the shell, on the real /play/<slug> page rather than 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;
  • 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);
  • the frame's own scroll offset is still zero after an ordinary turn.

The SDK announces it to the shell as arcade:presentation when the board mounts — not when the bridge mounts, so your menu and lobby stay full-bleed instead of being pinned inside the board's aspect box. ChannelGame makes that call for you; an app that renders its own board in place of ChannelGame must call useArcadeBridge().announcePresentation() itself, or the shell never letterboxes and there is no visible cause. The shell keeps the last shape it heard. The bridge is inert unless the app is actually embedded — running standalone still works, and the game simply fills whatever box it is given.

On-screen touch controls

touchControls (0.10.0) is how a keyboard game becomes phone-playable without touching its input code: an optional 4-way dpad and optional action buttons, where each value names the KeyboardEvent.key it stands for. A dpad names all four directions or is left out — 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), so a hook that samples key level reads it exactly like a held keyboard key.

touchControls: {
  dpad: { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' },
  buttons: [{ key: ' ', label: '💣' }],
}

ChannelGame mounts the overlay by itself on coarse-pointer devices while input is live, and the pad and buttons synthesize real window KeyboardEvents — the same events your input hook already listens for, so its held-key model, submit timing and move shape are unchanged. There is nothing to import or render; declaring the field is the whole opt-in. While the controls are up the bridge skips the arcade:presentation post, so the shell leaves the game its full frame and the controls land in the game's own gutter rather than on the board.

The hint renders above the pad on a phone, so write one for each pointer:

controlsHint: {
  keyboard: 'WASD to move, Space to bomb',
  touch: 'Last one standing wins',
}

The SDK picks the line the pointer calls for; a plain string is shown to both. Keep each line to about 46 characters or it wraps on a 320px screen.

Omit the field — the default — for a game whose board is already the input surface, such as a tap-to-place one; that game asks for the full frame with presentation.fullFrameOnTouch instead, and builds any touch-only affordance of its own — a tap-to-confirm bar, a fatter hit box — behind useCoarsePointer() (below). If a small screen also needs a different layout, decide it from the cell size you measure in the frame you were given rather than from a device guess, so a narrow desktop window benefits too and a tablet is not punished. There is no gamepad support and no key remapping.

What's in the box

Import surface, by entry point — see the table at the top of this page for why the split matters:

@xayaarcade/sdk — app code

  • ComponentsGame (the shell root, with persisted-session restore), ModeSelect, WalletLogin, ChannelLobby, ChannelGame, DisputeStatus, ErrorBoundary, Toast, RuntimeConfigGate. The on-screen touch controls are deliberately not in that list (0.10.0): ChannelGame auto-mounts them from your adapter's touchControls, so there is no component to import.
  • Hooksuse-channel-manager, use-gsp-polling, use-balances, use-xaya-name, use-arcade-wager, and useCoarsePointer() (0.11.0) — true when the device's primary pointer is a touch screen, exported so your own touch-only UI gates on the same signal the SDK's touch layer does. Do not re-derive it: the matchMedia read has to happen in an effect, because a read in render scope is memoized by the React Compiler and frozen at false. It answers false on the server and on the first client render, then flips after mount. useGspBlock() (0.15.1) is the chain tip: useGspPolling() no longer returns height/blockHash, so read them here, in the one component that displays or derives from them, and the per-block re-render stays local to it.
  • Channel primitives — board rules, state proofs, signatures, disputes, the channel manager, the open-channel and broadcast helpers.
  • The judge + loader — the packed wasm judge and the blob loader that fetches and re-verifies your rules by hash.
  • The arcade bridge — identity + brokered moves when embedded (below).
  • The adapter registryregisterAdapter, getAdapter, resolveGameId.

@xayaarcade/sdk/core — shared code (isomorphic)

Everything below is a runtime value your BoardRules, OpenChannel and packed codec must import from here rather than from the main entry:

  • The protobuf typesStateProof, ChannelMetadata and their schemas.
  • Encodinguint8ToBase64 / base64ToUint8, and sha256 / sha256Concat / bytesToHex / hexToBytes.
  • Channel value types + wire constantsNO_TURN, WINNER_UNDECIDED, DRAW_WINNER, and channelIdHex(id), the 64-character on-chain form the GSP matches literally.
  • ChannelConfig — the only way to build the cfg initialState takes.
  • buildChannelMetadata(participants, reinit) — the channel metadata message, built the host's way (0.9.0; stop hand-rolling the two-schema create() dance).
  • PlaceholderBoardState — the "no game yet" state your parseState returns for zero-length encoded bytes (0.9.0; delete your local copy — every game had an identical one, and required-method additions broke all of them at once).
  • The move bodiesdisputeMoveObject / resolutionMoveObject.
  • The judgePackedJudge / PackedHandle, plus the BoardRules, ParsedBoardState and OpenChannel types.

@xayaarcade/sdk/e2e — node only

The on-chain scenario harness: OnChainMoves, PolygonTxSender, Watcher, WsBroadcast (a real relay socket), InMemoryBus / DirectBroadcast (in-process), RecordingTxSender, quietChannelLogs(), and a re-export of buildChannelMetadata so harnesses need not add a second entry for it.

@xayaarcade/sdk/server

buildSecurityHeaders, for an app's middleware.ts or a serving layer.

The shell is the only signer

When your game is embedded in the arcade it holds no wallet. The shell owns the signature; the bridge's move broker (sendMove) carries your game's moves to the shell to sign as plain XayaAccounts.move()s. Never write a wallet integration into a game — no wagering contract calls, no signing. Free play first; wagering is a platform module (see Hosting & registration), and your game code stays wager-free.

The wager surface tracks the pooled payment queue (0.12.0): one FIFO per stake tier and seat count, pooled across every registered game rather than one queue per game. That makes 0.12.0 breaking against a pre-pooling ArcadeWager — upgrade the contract and the SDK together, since a pooled SDK against an unpooled deployment settles against a queue key that does not exist there. Nothing in your game code changes: the whole wager surface stays behind the platform module.

One line makes the SDK's styles reach your build

Tailwind v4's automatic source detection ignores node_modules, and the SDK's screens ship their classes compiled inside dist/. The only reason the lobby, the login and the in-channel HUD are styled at all is this line in src/app/globals.css:

@source "../../node_modules/@xayaarcade/sdk/dist";

The path is relative to that file and points at the repo-root node_modules where npm hoists the SDK. Delete it, move it, or mistype it and the build still succeeds — every SDK screen just renders completely unstyled. No unit test can see that, because the failure exists only in the emitted stylesheet.

npm run verify:css (scripts/check-sdk-css.mjs) is the check. It reads the built CSS — recursing over .next/static, so it finds the stylesheet under either the Turbopack or the webpack layout — and greps for a sentinel class used by an SDK component and by nothing in your repo. It therefore needs a npm run build first, and it fails loudly if it finds no CSS at all rather than passing on an empty search.

Keep the companion line directly below it:

@source not "../../scripts";

Automatic source detection skips node_modules and gitignored paths — it does not skip your own scripts/. Without that exclusion Tailwind scans the guard script itself and emits the sentinel from the guard's own source text, so the guard passes whether or not the real @source line above is present or correct. With the exclusion in place the numbers are unambiguous — per the header of scripts/check-sdk-css.mjs, the sentinel appears 0 times in the built CSS when the SDK @source line is removed and 4 times when it is present. Removing the exclusion silently disarms the check.

Debug logging

The channel/relay internals log on every tick (~8 Hz) and are silent by default. Turn them on with the public debugEnabled / dlog / dwarn gate:

  • At build time: NEXT_PUBLIC_DEBUG_CHANNEL=1.
  • At runtime in the browser: localStorage.setItem('<storagePrefix>_debug', '1') then reload (the key is prefixed by your storagePrefix).

Vendoring the SDK

The SDK is not published to an npm registry. It ships as a committed tarball under vendor/ and the repo depends on it by file: path. Re-cut it from a platform checkout with:

npm run vendor:sdk                                        # ../arcade-platform, beside your repo
ARCADE_PLATFORM=/path/to/arcade-platform npm run vendor:sdk   # any other layout

The default is the platform checkout sitting beside your game repo, because a sibling travels with the clone while a $HOME path is whatever that machine happened to leave there. Clone both repos into one directory and the plain form is simply correct.

The resolved checkout is a default, not a guarantee — read the banner. This is the one step where the wrong checkout produces a build that installs cleanly, compiles cleanly, and fails at runtime — a second, older arcade-platform clone sitting at that path produces exactly this. So before it packs anything the script prints the checkout it resolved — absolute path, SDK version, branch, commit, and whether it is behind its upstream — and refuses to pack a checkout that is behind (ALLOW_STALE_PLATFORM=1 overrides it, deliberately). A freshness unknown line means it could not reach the remote: that is "nobody checked", not a pass. Keep exactly one arcade-platform clone and the question never arises.

The version must bump on every repack. npm keys its cache and the lockfile's integrity by name + version, so re-packing the same version with different bytes installs the old bytes from a warm cache (silently) and hard-fails EINTEGRITY on a cold one. vendor-sdk.sh refuses to overwrite a same-version tarball with different bytes — bump the SDK version in the platform repo and re-run. The script records the source commit in vendor/PLATFORM-COMMIT so the tarball is reproducible.