@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:
| import | for |
|---|---|
@xayaarcade/sdk | your app code: React screens, hooks, the bridge, wallet + chain. |
@xayaarcade/sdk/core | your shared code — BoardRules, your OpenChannel, your packed codec. Isomorphic: it runs in the browser bundle and under a bare npx tsx. |
@xayaarcade/sdk/e2e | node-only scenario infrastructure: on-chain moves, WsBroadcast, quietChannelLogs(). |
@xayaarcade/sdk/server | node-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:
| Field | What it is |
|---|---|
gameId | Your game's wire id (must equal appConfig().moveNamespace). |
timing | The tick/turn timing your game runs on. |
ensureLoaded | Loads/verifies your rules blob before play. |
makeBoardRules | Builds the board-rules object the channel uses to judge states. |
makeOpenChannel | Builds the open-channel handler. |
Renderer | Your React board renderer. |
useInput | Your input hook. |
controlsHint | The controls hint shown to players: one string, or { keyboard, touch } for a line per pointer (0.18.0; below). |
stallGraceMs | How long to tolerate a stalled opponent before acting. |
presentation | How your game asks to be shaped, including fullFrameOnTouch (0.11.0; below). |
touchControls | Optional 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:
| Declaration | For |
|---|---|
touchControls on the adapter | A 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.fullFrameOnTouch | A 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.1for 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) —truetakes the whole frame on a coarse-pointer device instead of being letterboxed toaspectRatio. It defaults tofalse, 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 declarestouchControlsalready 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 <= clientHeightandscrollWidth <= 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
- Components —
Game(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):ChannelGameauto-mounts them from your adapter'stouchControls, so there is no component to import. - Hooks —
use-channel-manager,use-gsp-polling,use-balances,use-xaya-name,use-arcade-wager, anduseCoarsePointer()(0.11.0) —truewhen 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: thematchMediaread has to happen in an effect, because a read in render scope is memoized by the React Compiler and frozen atfalse. It answersfalseon the server and on the first client render, then flips after mount.useGspBlock()(0.15.1) is the chain tip:useGspPolling()no longer returnsheight/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 registry —
registerAdapter,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 types —
StateProof,ChannelMetadataand their schemas. - Encoding —
uint8ToBase64/base64ToUint8, andsha256/sha256Concat/bytesToHex/hexToBytes. - Channel value types + wire constants —
NO_TURN,WINNER_UNDECIDED,DRAW_WINNER, andchannelIdHex(id), the 64-character on-chain form the GSP matches literally. ChannelConfig— the only way to build the cfginitialStatetakes.buildChannelMetadata(participants, reinit)— the channel metadata message, built the host's way (0.9.0; stop hand-rolling the two-schemacreate()dance).PlaceholderBoardState— the "no game yet" state yourparseStatereturns 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 bodies —
disputeMoveObject/resolutionMoveObject. - The judge —
PackedJudge/PackedHandle, plus theBoardRules,ParsedBoardStateandOpenChanneltypes.
@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 yourstoragePrefix).
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-platformclone 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=1overrides it, deliberately). Afreshness unknownline means it could not reach the remote: that is "nobody checked", not a pass. Keep exactly onearcade-platformclone 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
EINTEGRITYon a cold one.vendor-sdk.shrefuses 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 invendor/PLATFORM-COMMITso the tarball is reproducible.