Agent skill

building-channel-games

For a game two to four people play against each other in real time or in fast turns: moves are signed off chain and only open, join, close and dispute touch the chain. Covers both the hosted Xaya Arcade — write rules and a board UI, upload, no servers of your own — and a standalone channel game you host yourself.

Raw markdown →


name: building-channel-games description: "Use when building a MULTIPLAYER game two to four people play against each other in real time or in fast turns on Xaya — a game channel: moves are signed off-chain and only open/join/close/dispute touch the chain. Covers both paths: the hosted Xaya Arcade at arcade.xaya.io (write rules + a board UI, no servers of your own — this is the DEFAULT, start here; Arcade games are 2-4 seats and React/Next only today) and a standalone channel game you host yourself. Also covers the rules.wasm judge ABI, @xayaarcade/sdk, compiling C++ rules to WebAssembly, WCHI stake wagering on a match, and how a game gets listed during the curated phase: rehearse it on the playground at test-arcade.xaya.io/attach, then get in touch. Routing is decided by where state lives — a signed off-chain channel between named players, not by turn speed. Use for 'I want to make a multiplayer game', 'make a game for the arcade', 'add/submit my game', 'game channel', 'state proof', 'dispute/timeout', 'rules.wasm', 'session key', 'relay', or work in arcade-platform / arcade-xayaman / arcade-xayaships / arcade-xayatrails / arcade-dungeonchannel / arcade-vector-sumo / xaya-arcade / arcade-fork-testing. For a persistent world, MMO, or a game where every move is its own on-chain transaction, use building-persistent-games instead."

Building channel games on Xaya

This corpus describes @xayaarcade/sdk 0.18.15. If the SDK you have vendored is older, read the package's own CHANGELOG.md (it ships in the tarball as of 0.9.0) before anything here — it is the authoritative record of every release. The breaking changes a game on an older SDK meets, one line each: 0.6.0, 0.7.0 and 0.8.0 reshaped the channel API (0.8.0 made move-request.signerName required); 0.12.0 pooled the payment queue by stake tier; 0.13.0 added ArcadeWagerState.queueSlice; 0.15.1 moved height/blockHash out of useGspPolling() into useGspBlock(); 0.16.0 made the wager client speak ArcadeWager v2 and only v2, required an EIP-55 canonical signing address, changed SignatureVerifier.recoverSigner to return string | null, and gave buildSecurityHeaders a second argument and a narrower policy; 0.17.0 renamed useChannelManager's moneyErrors/dismissMoneyError to sendErrors/dismissSendError; 0.18.0 made GameAdapter.controlsHint a string | { keyboard, touch } (any code that reads it as a string — every game repo's adapter test — narrows it or calls the exported resolveControlsHint), removed onGspUpdate() from sdk/src/hooks/use-gsp-polling, and removed lastDisputeTurn and numParticipants from MatchOutcomeInput; a game rendering the SDK's own screens touches neither of the removed ones. npm run sdk:freshness in a game repo tells you which version you are on. It compares against a platform checkout first ($ARCADE_PLATFORM, then a sibling ../arcade-platform), whose sdk/package.json is the only authority for the number, and falls back to the newest sdk-v* tag on the remote only when neither is reachable. Do not trust that fallback's verdict: the tag series is incomplete, so a newer untagged release reads as "current" when it is not — a missing tag is not a missing release. The authoritative pin for what your repo is actually carrying is the vendor/PLATFORM-COMMIT your vendor-sdk.sh wrote beside the tarball.

A game channel is two to four named players agreeing off-chain: every move is signed and exchanged peer-to-peer, and only open / join / close / dispute ever touch the chain. The chain is the referee of last resort, not the transport.

If you just want to ship a game: read ARCADE.md and stop. That file is the whole path — rules blob, board UI, a playground rehearsal, listed — and you need nothing else here unless it sends you back.

Your gameRead
Hosted on the Xaya Arcade: you write rules + a board UI, rehearse it on the playground, run no servers of your own. The default — start here.ARCADE.md, then WASM.md §A for the rules blob
Standalone: you deploy and operate your own referee GSP, relay and frontendSTANDALONE.md, then WASM.md §B
A persistent world, an MMO, or a game where every move is its own on-chain transactionnot a channel — use the building-persistent-games skill

Also here: WAGERING.md (WCHI stakes on a match), HIDDEN-INFORMATION.md (secrets on a channel whose every state is public), COMMIT-REVEAL.md (simultaneous moves at N seats), PITFALLS.md (traps by category), EXAMPLES.md (real repos), scripts/ (copy-and-adapt templates). The rest of this file is the protocol both paths share — read it once; it is what makes a channel game different from any other multiplayer game you have written.

1. When to use a channel

On-chain movesGame channel
Cadenceseconds–minutessub-second, every tick
Playersany number2+ — every seat signs
Gas per moveone txzero (only create/join/close/dispute touch chain)
Privacy between movesnoneoff-chain until resolved

Xaya channels are N-participant capable at the protocol level (metadata.participants), and N>2 is not theoretical: the shared relay lifted its 2-participant join cap (MAX_PARTICIPANTS, an operator setting in xaya-relay). On the Arcade the ceiling is hard — a submission must satisfy 2 <= min <= max <= MAX_SEATS, and MAX_SEATS is 4 (arcade-platform/submissions/src/preflight.ts, clamped to the chain's own board-player maximum). Going wider than two seats means budgeting for N-seat turn rotation, proofs carrying every live seat's signature, and an N-way settlement policy — none of which the 1v1 patterns in STANDALONE.md give you for free. And if moves are infrequent and gas is fine, do not build a channel at all: it costs you a relay, signed proofs, disputes and a WebAssembly build. Channels buy latency and free moves, nothing else.

2. Protocol lifecycle

1. Player 0 creates on-chain:  {"c":{"addr":"0xSessionKey0"}}
2. Player 1 joins on-chain:    {"j":{"id":"<channelIdHex>","addr":"0xSessionKey1"}}
3. Both exchange moves off-chain over the WebSocket relay, signed with session keys (no wallet popups).
4. State proofs accumulate: initialState + a chain of signed transitions.
5. Game ends -> the WINNER auto-sends a resolution on-chain (never the loser — the loser has no
   incentive to pay gas for their own loss).
6. Opponent goes offline mid-game -> the other side files a dispute -> after DISPUTE_BLOCKS blocks
   with no resolution, the channel force-closes in the disputer's favor.

A separate channel-timeout constant is unrelated to disputes: it only reaps channels that never got a second participant. STANDALONE.md carries both block constants with the referee GSP's source line as their authority — do not quote a value for either from memory.

3. The XayaX sign prefix (get this exact or every proof is silently rejected)

The referee verifies state-proof signatures via XayaX's verifymessage JSON-RPC, which does EIP-191 recovery over a chain-prefixed message:

keccak256("\x19Ethereum Signed Message:\n" + len(prefixed) + prefixed)
  where prefixed = "Xaya signature for chain {chainId}:\n\n" + original_message

Exact string, from arcade-platform/sdk/src/lib/crypto/eth-signatures.ts, where xayaPrefixMessage() is nothing but XAYA_SIGN_PREFIX + message:

const XAYA_CHAIN_ID = 137; // Polygon mainnet
const XAYA_SIGN_PREFIX = `Xaya signature for chain ${XAYA_CHAIN_ID}:\n\n`;

That prefixing is applied on every off-chain sign and every verify. Signer and verifier must agree byte-for-byte or the referee recovers a different address and rejects the whole proof chain — with no error saying so; you just see every proof refused. There is no partial-prefix or lenient mode. This section is the literal's only home in either skill: copy it from here, never retype it.

4. Turn model, and turn-interleaving for simultaneous games

Xaya channels are strictly turn-based at the protocol level — WhoseTurn() returns a single participant index (or "no turn" once finished), and the proof chain requires strict P0 -> P1 -> P0 alternation. A turn-based board game maps on directly: each player submits one input per net tick, and the next player's move both records their own input and (if this is the second submitter for the round) advances the shared game tick.

For a genuinely simultaneous-action game (racing, FPS) on that same strictly-turn-based protocol, the standard pattern is turn-interleaving. A round takes two protocol messages — P0 submits and the turn switches, P1 submits and the tick advances — but neither input is applied alone: both go into the physics together, so neither player can react to the other within the same tick batch.

if (!pb.has_pending_input()) {
  *newState.mutable_pending_input() = input;   // P0's turn: store input, switch to P1
  newState.set_turn(1);
} else {
  PlayerInputs playerInputs;                   // P1's turn: apply BOTH through game ticks
  playerInputs[0] = ProtoToInput(pb.pending_input());
  playerInputs[1] = ProtoToInput(input);
  for (int i = 0; i < TICKS_PER_NET_TICK; i++) gs = updateGameState(gs, playerInputs);
  newState.clear_pending_input();
  newState.set_net_tick(pb.net_tick() + 1);
}

This is why rules built around the turn-based protocol cannot simply "advance one tick for one player's input" for local prediction — see §6.

Turn-interleaving has a hard limit, and it is not stated often enough: P1 can read P0's stored input out of the state before choosing. The pattern is fair only when the second submitter gains nothing from seeing the first — a physics batch, where both inputs land in the same tick and neither player could have reacted anyway. When the choice itself is the secret (a thrown attack, a bid, anything rock-paper-scissors shaped), turn-interleaving is broken and no amount of care with pending_input fixes it: the value is sitting in a state the opponent holds a signed copy of. The answer is a commitment — each seat publishes a hash of its choice, and only once every seat has committed does anyone reveal. COMMIT-REVEAL.md is that protocol, at N seats, including what the rules do when a seat commits and never reveals.

4b. Hidden information, in one sentence

Every participant holds the full signed state and the referee re-executes every transition, so there is no such thing as state one seat cannot read. A hidden hand placed in the board state is readable out of the proof chain by the opponent. Secrets live client-side; what crosses the wire is a commitment to a secret and later a proof about it. HIDDEN-INFORMATION.md is the full pattern — fog of war, graduated disclosure, and the payoff table that makes refusing to reveal the losing move.

5. Dispute vs resolution

MoveJSONEffect
Dispute{"d":{...}}Starts the DISPUTE_BLOCKS timer. Requires the game still in progress — a dispute for an already-finished game is rejected.
Resolution{"r":{...}}Updates on-chain state immediately; if the proven state has a winner, the channel closes right away.

When the game ends the winner sends the resolution, never the loser (no incentive to spend gas declaring your own loss). Detect it in the channel manager: if it is nobody's turn and this client won, send the resolution; if the opponent is unresponsive mid-game, send a dispute instead — STANDALONE.md carries the TypeScript for both branches. One exception: a draw has no winner, so no win path fires; the lowest-index participant sends the closing resolution instead, or the channel hangs open forever. For a wagered game the draw close is also what triggers settlement (WAGERING.md).

A dispute-timeout close never marks the board finished. The referee closes the channel without advancing the on-chain state to a finished position, so a UI that gates "you won" / "return to lobby" on a finished flag hangs forever ("I disputed, it stayed open"). Derive that the match is over from the channel leaving the referee's open setmatchOver = finished OR (seen-open AND now-gone).

Do NOT infer who won from the last dispute's whoseTurn. Being on the clock when a dispute expires does not decide the match: the Arcade referee asks the game's own blob (arcade_resolve_timeout) at every seat count and settles on its answer — which may name the seat that timed out as the winner, or split the pot by arcade_share_weights — falling back to the survivor close only when that answer is unusable (arcade-platform/engine/gsp/logic.cpp, ProcessExpiredDisputes). The SDK deliberately removed exactly that lastDisputeTurn === playerIndex ? 'lose' : 'win' mirror, because it tells the loser they won and the winner they forfeited (sdk/src/lib/channel/match-outcome.ts — read its header). On a closed-but-not-finished channel report a neutral "match over, return to lobby"; only a finished board's own winner is authoritative.

What the player sees when it ends, if you render the SDK's screens. As of SDK 0.15.5 the close recap holds until the player presses Back to Lobby, paid or free — the free channel's old auto-navigation is gone and its "Returning to lobby…" line now reads "Match settled." (sdk/CHANGELOG.md, 0.15.5). ChannelGame's props and exports are unchanged, so a game rendering it gets this for free; a custom shell that relied on the free-channel auto-navigation must now leave through the recap button. Since 0.18.0 the player who ends the match themselves gets that screen too: pressing Close (Forfeit) used to drop them straight back to the lobby with nothing said while their opponent got the full result overlay, and it now shows "You Lose!" with the reason — or, above two seats where the channel stays open behind them, "You left — the match continues without you." and later "Your seat has been timed out.". A leave the wallet rejected, or one whose timeout did not land on the leaver's own seat, shows no recap at all, because nothing happened. None of this changes the derivation above — the close is still detected by seen-open-then-gone, and nothing may gate on finished.

6. Making it feel real-time, and timing security

Channels are turn-based at the wire level: P0 sends -> relay -> P1 processes -> P1 sends -> relay -> P0 processes. For a fast game, waiting for that round trip before updating the display causes visible stutter. The fix is a client-side local simulation independent of the proof exchange:

WITHOUT local sim (stutters): Input -> rules -> sign -> relay -> wait... -> relay <- verify <- render
WITH local sim (smooth):      Input -> LocalSim (fixed rate) -> Renderer     (immediate feedback)
                                          | async, in the background
                                       ProofChain (rules) <-> relay <-> peer
                                          v  reconcile: snap LocalSim to the authoritative proof state

You write all four pieces yourself — the SDK ships no local simulation, latency tracker or reconciliation helper (its whole real-time surface is timing: 'realtime' on the adapter, which switches the in-game telemetry strip to show the net tick and nothing more), so read the sketch below as shape, not API. The local simulation is an integer-for-integer TypeScript mirror of the blob's physics — the same fixed-point arithmetic, the same tick order, the same rounding — pinned to the blob by a test that replays the committed golden trace (WASM.md §A4) through the mirror and asserts every step byte-identical to the trace; a mirror that drifts by a sign, a rounding mode or a -0 fails that test before it reaches a player. A board-state converter turns the authoritative board JSON back into the mirror's state shape; a latency tracker measures proof round-trip locally via performance.now() and derives a command delay D; a game loop ticks the mirror every frame and reconciles it on each new proof.

onFrame((delta) => {                    // every frame — drives rendering, never waits for proofs
  sim.setInput(playerIndex, currentInput);
  while (accumulator >= tickInterval) { localState = sim.tick(); accumulator -= tickInterval; }
});
onBoardState((boardState) => {          // whenever a new authoritative state arrives
  sim.applyAuthoritativeState(boardStateToLocalState(boardState, ctx), netTick);
  sim.setCommandDelay(latencyTracker.getRecommendedD());
});
  • Reconciliation: on each authoritative proof, load its state into the mirror and replay the local inputs issued after that proof's tick from the pending-input buffer, so the player's own view stays ahead of the wire without diverging from it; smoothing over a few frames absorbs whatever correction remains. Because the mirror is bit-exact, a replay from an authoritative state reproduces exactly what the blob will compute for those inputs — the correction is only ever the opponent's unknown inputs, never arithmetic. Pause condition: lagMs = (localGameTick - syncNetTick * ticksPerNetTick) * msPerTick; pause and show a waiting overlay when lagMs > commandDelayMs.
  • Rate limiters: both sides submit on a fixed timer, not as fast as possible; an auto-move helper must return null when there is no pending input, or a tight loop runs the game at double speed; a ticks-per-net-tick multiplier holds wall-clock speed constant when you change that timer — drop the submit rate for a bad link and raise the multiplier by the same factor.
  • None of those rates are platform constants. The protocol fixes no tick rate, submit interval or command delay, and neither the SDK nor the referee reads one — they are per-game tuning. For scale only: xayaman defaults to a 120 ms submit interval (INPUT_INTERVAL_MS in arcade-xayaman/src/hooks/use-xayaman-input.ts) and 6 sim ticks per net tick (TICKS_PER_NET_TICK in arcade-xayaman/src/game/types.ts, mirroring rules/game/constants.hpp). Measure your own game; never inherit another's numbers as defaults.
  • Latency behaviour: while corrections are small they are invisible; as latency climbs, the opponent's rendering shows correction artifacts; once lag exceeds D, or on disconnect, the game pauses with "Waiting for opponent…". Your own view is always smooth — it reads local input directly.
  • Reach for this layer only when the core loop needs sub-turn responsiveness (movement, aiming, physics) — a turn-based board game does not need it.

Board-state converter pattern. Convert the authoritative board JSON into whatever shape your renderer or local sim consumes, carrying a persisted context (loaded assets, static geometry, your player index) for everything not in the proof — never invent it. Handle the pre-join shape ({ phase: "waiting for opponent" }) explicitly; it is not an error state. Any field that is derived state but NOT part of the signed proof (a "time since X" seeded from 0 and corrected on the next snap) must be documented at the call site: silently defaulting one to 0 makes an effect expire instantly when its base tick is wrong. EXAMPLES.md has a real board JSON.

Timing security. The protocol carries no timestamps. All timing is local — performance.now() on send and receive, RTT from the gap between a submission and the next proof, D derived purely from local measurements. Nothing clock-related crosses the wire, so there is nothing to fake; combined with the relay being a dumb forwarder, there is no relay-side surface for a timing exploit, and relay logic would only add latency and attack surface. Debug diagnostics: a compact always-visible bar plus a toggleable panel showing tick, sync net-tick, lag, latency, turn count, referee height and alert flags (paused / divergence count / peer disconnect), pushed from the loops you already run.

7. Invariants you must not break

  • Wire-frozen constants. The judge ABI, the move envelope, the shell's postMessage protocol and registered game ids never change meaning; new messages may only be appended. ARCADE.md §4 has the exact frozen list.
  • Rules must ignore invalid moves, never crash. Anyone can send any JSON at your game; an unparseable or illegal move is a no-op, not a fatal error.
  • Verify every proof you receive; never trust the wire. Walk every transition, re-execute each move through your own rules and compare the result against the claimed state, check each signature via ecrecover with the chain prefix (§3), and drop an invalid proof entirely — never partially apply it; surface it as a divergence indicator in the UI. A cheater's rules must produce byte-identical results to yours or the proof dies locally, before it ever reaches the chain.
  • Validation runs on attacker-controlled bytes, before any signature is checked. During dispute and resolution the referee parses an attacker-supplied state and validates it pre-auth. If a malformed state passes validation and later reaches an abort, every node crashes identically on the same on-chain move — a consensus-fatal, pre-auth denial of service, one cheap move halting the game. So: validation returns false, never aborts; compare unsigned fields unsigned; bound every value used as an index; cap the total count of every attacker-sized collection so proof-iteration cost stays bounded; and gate it with a test feeding hand-crafted malicious states in that asserts each is rejected. STANDALONE.md has the three-layer C++ implementation, ARCADE.md the rules-blob equivalent.

8. Foundation facts you need even if you never load the other skill

  • Names and moves go through the XayaAccounts contract on Polygon — the address is in building-persistent-games' constants table; load that skill for the full section.
  • WCHI has 8 decimals (arcade-platform/sdk/src/lib/wager/format-wchi.ts) — a stake is a bigint in those units, never a float.
  • The off-chain sign prefix is §3 above, and §3 is its only home in either skill.
  • Invalid moves are ignored, not fatal — for your rules and for the referee alike.
  • The determinism law: the same ordered moves must produce byte-identical state on every node, so no floats, no wall clock, no unseeded RNG, no hash-map iteration order in anything feeding a signed proof; load the building-persistent-games skill for the full section, and see WASM.md for the gates that prove it.
  • Names are permanent: a registered Xaya name is an ERC-721 you own, and a registered game's on-chain identity cannot be renamed — choose ids once, deliberately.

9. How to verify a claim in this skill

Authority beats this file, and this file beats memory. Four sources settle everything here:

  1. arcade-platform/engine/judge/wasm_judge.cpp — the rules-blob ABI as executed: the required-export list (kRequiredFuncs) and the fuel, memory and output-buffer caps.
  2. arcade-platform/docs/ARCADE-ABI.md — the written contract for that same ABI, kept beside the judge; if the two disagree, the judge wins.
  3. arcade-platform/submissions/src/preflight.ts — every submission gate: artifact size caps, the seat range, the cfg suffix, the base-path check, slug and gameType rules.
  4. arcade-platform/sdk/src/ — the SDK surface you code against; sdk/package.json is the only authority for its version (never quote one from a document), and sdk/src/lib/crypto/eth-signatures.ts backs §3.

Endpoints are never a fact you hardcode: your bundle reads them from the arcade-config.json served beside it at runtime, and the arcade's move namespace is whatever that file serves — it differs per plane, so curl it rather than remembering it.

Verified against arcade-platform/sdk/src/lib/crypto/eth-signatures.ts, arcade-platform/submissions/src/preflight.ts and arcade-platform/engine/judge/wasm_judge.cpp.

Modules

  • ARCADE.mdThe one file to read if you just want to ship a game: the whole flow from a template clone to an accepted submission.
  • STANDALONE.mdRunning a channel game without the Arcade: your own referee GSP, relay, session keys and deployment.
  • WASM.mdCompiling deterministic C++ rules to a freestanding WebAssembly judge, and authoring the golden traces that prove it.
  • WAGERING.mdPutting a WCHI stake on a match: escrow, the payment queue, and paying out N players.
  • HIDDEN-INFORMATION.mdEvery seat holds the full signed state, so nothing in it is secret: where a secret lives instead, and what crosses the wire in its place.
  • COMMIT-REVEAL.mdSimultaneous moves on a strictly turn-based protocol, at N seats — the commitment, the reveal window, and the seat that never opens theirs.
  • PITFALLS.mdThe failures this platform actually produces, written so you recognise one in seconds.
  • EXAMPLES.mdShipped channel games and what each one is worth reading for.