Standalone channel games: hosting the whole stack yourself
This is the self-hosted path: you build and run the on-chain referee (a libxayagame GSP), you run a WebSocket relay, you ship your own frontend, and you own every line of the consensus code. Nothing here is required to publish a game: if you want to ship without operating anything, read ARCADE.md instead; the hosted Arcade is the default execution of this same protocol and does every part of this file for you. Come here when you need a deployment nobody else controls, a seat model or presentation the hosted path cannot express, or you are porting an existing standalone game.
Read SKILL.md first. It carries the protocol facts this file assumes: the channel lifecycle, the strictly turn-based model, the off-chain sign prefix, dispute vs resolution, and the real-time reconciliation patterns. This file is the implementation half: the C++ referee, the browser-side machinery, consensus hardening, and the deploy. Wagering on top of a channel → WAGERING.md. Compiling your rules to WebAssembly → WASM.md.
Reference implementation, cited throughout: the pre-split standalone Xayaman
(xaya/xayaman, branch main) - one game running its own GSP + relay + frontend,
with the C++ referee in engine/gsp/ and engine/board/ and the browser half in
src/lib/channel/, src/lib/crypto/ and src/lib/relay/. Archived - do not start here. It
is also private, so the xayaman paths below describe its shape; they are not files you can open (paths prefixed with another repo, such as arcade-platform/, are). It is frozen
at the pre-split commit and is cited only because it is a complete, working, end-to-end standalone
stack; it is not a template, it is not maintained, and no new game should be forked from it. (xayaships_frontend /
polyxayaships in EXAMPLES.md carry the same superseded mark.) The copy-me template for new
work is the Arcade one: see ARCADE.md.
That reference stack is strictly 2-participant, and every code sample below assumes a pair.
Going wider means budgeting for N-seat turn rotation, proofs that carry every live seat's
signature, and an N-way settlement policy: the protocol supports it
(metadata.participants), the reference implementation does not exercise it.
Trust this file over memory for the relay auth string and the session-key storage prefix. Both are exact-match protocol details that silently break when approximated, and both are quoted below from their authority files: re-read them at the cited paths before you deploy.
1. What you own, and the two block constants
| Piece | Who provides it | Notes |
|---|---|---|
| GSP referee | you build it | libxayagame daemon on the xaya::ChannelGame base: §2 |
| Board rules (C++) | you write them | the consensus surface; §2 and §9 |
| Channel protobufs | libxayagame, unchanged | libxayagame/gamechannel/proto/{metadata,signatures,stateproof,broadcast}.proto |
| WebSocket relay | shared codebase, you run an instance | github.com/xaya/xaya-relay (public); one instance per game |
| Frontend + channel manager | you write it | §§3-8 |
| XayaX (Polygon → Xaya bridge) | published image | run one shared instance on a multi-game host: §10 |
Two block windows govern the endgame. The reference GSP's compiled-in defaults are at
xayaman/engine/gsp/logic.hpp: DISPUTE_BLOCKS = 10 and CHANNEL_TIMEOUT_BLOCKS = 12 (the
repo prefix matters: arcade-platform carries the same two names in its own engine/gsp/logic.hpp). They are
that GSP's values, not a platform constant: your GSP picks its own. Nor is a compiled-in
constant the only shape: the Arcade's multi-game host GSP keeps the same two names only as
fallbacks and reads a per-game window off its registry row instead
(COALESCE (gr.dispute_blocks, …) / COALESCE (gr.channel_timeout_blocks, …) in
arcade-platform/engine/gsp/logic.cpp, columns defaulting to 10 and 12 in
arcade-platform/engine/gsp/schema.sql). The two are unrelated: DISPUTE_BLOCKS is the timer a
dispute runs before the referee force-resolves the channel, while CHANNEL_TIMEOUT_BLOCKS only
reaps channels that never got a second participant.
2. The GSP referee (C++): create / join / dispute / resolve
The consensus half is a libxayagame GSP built on the xaya::ChannelGame base: the referee
that watches on-chain channel moves and force-closes disputes. Everything below is verified
against the reference GSP (engine/gsp/, engine/board/).
Your logic class (engine/gsp/logic.hpp → YourLogic : public xaya::ChannelGame):
class YourLogic : public xaya::ChannelGame {
YourBoardRules boardRules;
const xaya::BoardRules& GetBoardRules() const override { return boardRules; }
void SetupSchema(xaya::SQLiteDatabase& db) override; // SetupGameChannelsSchema(db) + your tables
void GetInitialStateBlock(unsigned& h, std::string& hash) const override; // per-chain genesis (POLYGON case)
void InitialiseState(xaya::SQLiteDatabase& db) override; // empty (or the wager bootstrap: WAGERING.md)
void UpdateState(xaya::SQLiteDatabase& db, const Json::Value& blockData) override; // the dispatch, below
Json::Value GetStateAsJson(const xaya::SQLiteDatabase& db) override; // channels + stats → RPC JSON
};
ChannelGame provides ProcessDispute() / ProcessResolution(), which call VerifyStateProof()
for you: replaying every transition through your board's ApplyMove and checking every
signature. You never verify a proof by hand. main.cpp wiring is the same
xaya::SQLiteMain(config, gameId, rules) as a non-channel GSP, with two channel extras: rules
is your ChannelGame, and the RPC server needs the channel instance factory:
xaya::ChannelGspInstanceFactory instanceFact(rules); config.InstanceFactory = &instanceFact;
(engine/gsp/main.cpp; the class is in libxayagame/gamechannel/gsprpc.hpp).
The move dispatch (UpdateState): each on-chain move is one key; dispatch on it, ignore
unknown or multi-key moves:
for (const auto& mv : blockData["moves"]) {
const std::string name = mv["name"].asString();
const auto id = GetIdFromMove(mv); // for `c`, the channel id = this move's txid/mvid
const auto& d = mv["move"];
if (!d.isObject()) continue; // not an object → not a move of ours
if (d.size() > 1) continue; // exactly one action per move
HandleCreateChannel (db, d["c"], height, name, id);
HandleJoinChannel (db, d["j"], name, id);
HandleAbortChannel (db, d["a"], name);
HandleDeclareLoss (db, d["l"], name);
HandleDisputeResolution (db, d["d"], height, /*isDispute=*/true);
HandleDisputeResolution (db, d["r"], height, /*isDispute=*/false);
}
// wager admin commands arrive SEPARATELY in blockData["admin"]: see WAGERING.md
ProcessExpiredDisputes(db, height);
TimeOutChannels(db, height);
On-chain move payloads: every channel move rides the normal envelope
(XayaAccounts.move('p', playerName, '{"g":{"<gameid>":{ <one key> }}}', …)):
| Key | JSON | Sent by | Effect |
|---|---|---|---|
| create | {"c":{"addr":"0x<sessionKey0>"}} | Player 0 | opens a 1-participant channel; channel id = this move's id |
| join | {"j":{"id":"<channelIdHex>","addr":"0x<sessionKey1>","seed":<uint>}} | Player 1 | adds participant 2, sets the initial board state, bumps reinit (seed optional PRNG) |
| abort | {"a":{"id":"<channelIdHex>"}} | creator | cancels an un-joined channel |
| declare loss | {"l":{"id":"<channelIdHex>","r":"<reinit b64>"}} | loser | concede; r must equal the current reinit |
| dispute | {"d":{"id":"<channelIdHex>","state":"<StateProof b64>"}} | either | opens the DISPUTE_BLOCKS timer against an active game |
| resolution | {"r":{"id":"<channelIdHex>","state":"<StateProof b64>"}} | winner | pushes the latest proof; if it proves a finished state, closes now |
The joiner learns channelIdHex by reading the open-channels list out of the GSP's
getcurrentstate (the same feed the channel manager bootstraps from, §5): the create move's id
is the channel id.
reinit (what it is). A channel's reinit is the id/state that scopes its signature domain for
the current participant set. On create the channel is Reinitialise(meta, ""); on join,
UpdateMetadataReinit(id, meta) bumps it (participants went 1→2) and the channel is
Reinitialised with the initial board state. A StateProof's initialState is a reinit state;
reinitId tags which reinit an off-chain message belongs to, so a proof from a superseded
participant set is rejected. Off-chain code threads reinitId through
(processOffChain(reinitId, proof), §5); on-chain, declare-loss carries the base64 reinit so the
GSP confirms you are conceding the current game.
Board rules (engine/board/): the bridge from your game logic to the protocol:
// libxayagame's proto-board template takes BOTH proto types (state AND move);
// alias it once, as the reference implementation does (engine/board/board.hpp):
using BaseProtoBoardState =
xaya::ProtoBoardState<proto::BoardState, proto::BoardMove>;
class YourBoardState : public BaseProtoBoardState {
bool IsValid() const override; // runs on UNTRUSTED input - §9
bool ApplyMoveProto(const proto::BoardMove& mv,
proto::BoardState& newState) const override; // your rules; false on any bad input
int WhoseTurn() const override; // participant index, or NO_TURN when finished
unsigned TurnCount() const override; // THE freshness counter - must rise on every move (PITFALLS.md #18)
Json::Value ToJson() const override; // the shape your board-state converter reads (SKILL.md)
};
class YourBoardRules : public xaya::ProtoBoardRules<YourBoardState> { // ONE template param
// REQUIRED - pure virtual in BoardRules; return ORIGINAL:
xaya::ChannelProtoVersion GetProtoVersion(
const xaya::proto::ChannelMetadata& meta) const override;
// Optional: override ParseState too if the empty pre-join state ("") needs
// special handling (the reference implementation does - engine/board/board.hpp).
};
// The starting position is a plain free function your join/start handler
// serializes into the channel's reinit state (NOT a rules-class member):
proto::BoardState InitialBoardState(const proto::Config& config);
ParseState wraps IsValid, and ProcessDispute/ProcessResolution call it on
attacker-supplied bytes before signatures are checked, so IsValid / WhoseTurn /
ApplyMoveProto must never CHECK/abort on bad input. This is the most important consensus
rule in the whole stack: read §9 before you write them.
Protobuf schemas: reuse the channel protos from libxayagame unchanged
(libxayagame/gamechannel/proto/{metadata,signatures,stateproof,broadcast}.proto). Author your game's own
proto/{boardstate,boardmove}.proto (+ optional gameconfig). Generate C++ with protoc (GSP
side) and TypeScript with buf (target=ts, frontend side).
Building the channel GSP: identical to a plain libxayagame GSP Dockerfile except: also
compile the gamechannel protos; link the channel libs -lgamechannel -lchannelcore -lxayagame -lxayautil -lethutils (a non-channel GSP links just -lxayagame -lxayautil); source layout
board/ + gsp/ (logic/main/schema/gamestatejson) + proto/. The plain-GSP build recipe it
extends is not in this skill: one sentence of it: a libxayagame GSP is a C++ daemon built against
the libxayagame image, run with --xaya_rpc_url pointed at XayaX and its own --datadir; load
the building-persistent-games skill for the full section.
Dispute lifecycle + timeouts. ProcessExpiredDisputes force-resolves a channel once the
DISPUTE_BLOCKS timer elapses; TimeOutChannels reaps channels that never got a second
participant. Neither may compute the deadline with an unsigned subtraction: on a low-height
chain (a fresh fork or regtest) height - DISPUTE_BLOCKS underflows and every dispute looks
already expired. Write the comparison in sum form (disputeHeight + DISPUTE_BLOCKS <= height):
it avoids the subtraction entirely, which is why the Arcade's host GSP uses it in both
passes: its own comment there says the sum form "needs no manual underflow guard" (arcade-platform/engine/gsp/logic.cpp,
ProcessExpiredDisputes and TimeOutChannels). A if (height < DISPUTE_BLOCKS) return; guard in
front of a subtraction also works, but only for as long as nobody forgets it.
And do not assume a timeout simply awards the win to the disputer: that is one policy, not the
protocol. The Arcade's host GSP asks the game's own blob (arcade_resolve_timeout) what a timeout
means at every seat count and settles on its answer, falling back to the survivor close only when
that answer is unusable; above two seats a non-finished answer becomes a reinit rather than a
close, and the survivors keep playing. ARCADE.md §6 has that contract. A GSP you write yourself owns
this decision entirely: decide it deliberately. Either way a dispute-timeout close never sets the
board's finished flag; that UI trap, and what you may and may not infer from it, are in
SKILL.md §5.
3. Session keys
Session keys let every off-chain move be signed instantly with no wallet popup. MetaMask (or equivalent) is used only for on-chain transactions: create, join, dispute, resolution.
Storage prefix (the key builder in src/lib/crypto/session-key.ts): xayaman_session_<channelIdHex>, with
a lazy one-time migration from a legacy prefix left over from a pre-rename build: run once,
best-effort, never blocking key access if localStorage is unavailable:
const STORAGE_PREFIX = 'xayaman_session_';
const LEGACY_STORAGE_PREFIX = 'racer_session_';
export function getSessionKey(channelIdHex: string): `0x${string}` {
if (typeof window === 'undefined') return generatePrivateKey();
migrateLegacySessionKeys(); // copies any racer_session_* forward, once
const key = `${STORAGE_PREFIX}${channelIdHex}`;
const existing = localStorage.getItem(key);
if (existing) return existing as `0x${string}`;
const pk = generatePrivateKey();
localStorage.setItem(key, pk);
return pk;
}
The example above throws where localStorage refuses the read or the write. The SDK's own
getSessionKey does not: it holds the key for the page and saves it later, and its lobby refuses
create and join while sessionKeySaved(wallet) is false (ARCADE.md §3 step 3). A game that keeps
its own getter and lobby should do the same.
If you fork this pattern for a new game, pick your own <yourgame>_session_ prefix and drop
the migration shim: it exists only because that game was renamed mid-project from an earlier
codename. Prefix collisions between two games served from one origin are a real hazard, not a
theoretical one.
The session key's address is what goes on chain, in the "addr" field of the create/join move.
The GSP checks off-chain signatures against that address, never against the player's main wallet.
4. WebSocket relay auth
All Xaya channel games can share one relay codebase: github.com/xaya/xaya-relay, but each game runs its own relay
instance pointed at its own GSP (single GSP_URL; same Docker image, different env). Deploy
shape in §10.
Auth message (verified exact format, src/lib/relay/ws-broadcast.ts: RELAY_AUTH_AUDIENCE
for the audience constant and the authMessage template literal that follows it, client side;
xaya-relay/src/auth.ts getAuthMessage server side. Cited by symbol, not line: this file is the one that
moves most, and the two repos that carry it disagree on line numbers):
Xaya Relay Auth
Audience: <aud>
Channel: <channelIdHex>
Timestamp: <unixSeconds>
const RELAY_AUTH_AUDIENCE =
(typeof process !== 'undefined' && process.env.NEXT_PUBLIC_RELAY_AUDIENCE) ||
'xayaships-relay-v1';
const channelIdHex = channelId.toString(16).padStart(64, '0');
const timestamp = Math.floor(Date.now() / 1000);
const authMessage =
`Xaya Relay Auth\nAudience: ${RELAY_AUTH_AUDIENCE}\nChannel: ${channelIdHex}\nTimestamp: ${timestamp}`;
const signature = await sessionSigner.signMessage(authMessage); // Xaya-chain-prefixed: SKILL.md §3
The audience identifies the relay deployment, not the game. Its job is cross-relay replay
protection: it binds a signature to this relay so one captured here cannot be replayed against
another relay sharing the same channelId format. The relay does exact single-string equality
(xaya-relay/src/auth.ts hard-rejects a mismatched audience before spending CPU on signature recovery), so
one value per deployment and every client connecting to it sends that same value: two clients
with different audiences cannot both be served by one relay. Because each game runs its own relay
instance, in practice that means one audience per game; xayaships-relay-v1 is only the
client-side fallback baked into the snippet above, carried over from the relay's original
game. The server has no default: with auth enabled and RELAY_AUTH_AUDIENCE unset it exits at
startup (xaya-relay/src/index.ts). Set it on the relay and set NEXT_PUBLIC_RELAY_AUDIENCE on the client to
the identical string.
The server recovers the signer from the (chain-prefixed) auth message, checks it matches the
claimed address, checks timestamp freshness against its AUTH_TIMEOUT window (xaya-relay/src/auth.ts,
env-tunable: read your deployment's value), and enforces single use: an accepted
(address, channel, signature) triple is remembered for the freshness window and a replay of it
inside that window is rejected, so a captured signature cannot re-trigger a seat takeover.
The auth message must match your relay BUILD byte-for-byte. The current relay implements
the Audience: line and treats it as mandatory: with auth enabled and no RELAY_AUTH_AUDIENCE set
the server refuses to start, and there is no optional or dual-dialect mode. Relay builds predating
that change sign Xaya Relay Auth\nChannel: <hex>\nTimestamp: <ts> with no audience line. Before
wiring auth, open xaya-relay/src/auth.ts in the relay build you deploy and make the client sign exactly the string the
server recovers; a one-line mismatch rejects every join, with no partial-credit failure mode.
Server → client message types: subscribed, gsp_state, gsp_tick (a block's header in
place of an unchanged full state, sent to a client whose subscribe set tick), gsp_status,
wager_state/wager_status (sent to a client whose subscribe set wager), joined (with
participants), peer_joined, peer_left, replay (a header before N buffered messages), and
error. Binary frames carry the actual StateProof / BroadcastMessage payloads; JSON frames are
control messages: dispatch on typeof event.data === 'string'.
The relay caps addresses per channel at MAX_PARTICIPANTS (xaya-relay/src/channel-router.ts), an
env-tunable default, not a fixed two: read it from your deployment rather than
assuming.
It also runs a liveness sweep, and that is where a silent drop's peer_left comes from. A link
that dies silently - a phone losing signal, a laptop suspended, a killed process - never fires its
own close event, so a socket with nothing to send can stay nominally open for minutes after the
network under it is gone. The relay therefore pings every registered connection on a fixed interval
and terminate()s any that did not answer the previous ping (HEARTBEAT_MS, xaya-relay/src/limits.ts,
env-tunable: read your deployment's value). A terminate fires the close event the dead link never
sent, so the drop takes the ordinary path: leaveChannel, then peer_left to the rest of the
channel. That means the detection window is between one and two intervals, and it is what starts the
game's own disconnect grace: set the interval well below the stall timeout your client offers a
dispute on, or the game's timeout gets there first and the sweep buys you nothing. Browsers answer a
ping automatically, so nothing is required of the client.
The relay is a dumb forwarder: no game logic, no matchmaking, no tick loop, no timestamps of
its own. That is exactly what SKILL.md's timing-security argument rests on. It does not pass a
binary frame on byte for byte, though: it reads the frame as a BroadcastMessage, keeps only its
reinit (field 1) and proof (field 2) bytes as they came, and sends the others a frame rebuilt
from those two plus from (field 3): the address the sending socket joined the seat with, in
lowercase, in place of any from the sender wrote. A frame it cannot read that way is dropped,
not forwarded, and the sender is not told (stampSender and forwardMessage in
xaya-relay/src/channel-router.ts). A frame that arrives over a relay link is stamped the same
way, and only for a seat that link holds (onFrame in xaya-relay/src/mesh.ts). The address is
authenticated only while auth is on: with RELAY_AUTH=false it is whatever the join claimed, so
the stamp vouches for nothing. libxayagame's broadcast.proto declares fields 1 and 2 alone, so a
client built on it skips from as an unknown field, and any field of your own beside them never
reaches the peers.
@xayaarcade/sdk trusts that stamp, so the stamping relay goes first. The channel manager drops
a peer's proof whose from names no participant of the channel, keys the
proofs waiting to be verified by from, and once a proof from a sender fails verification drops
everything that sender sends, unverified, until the reinit changes (processOffChain in
arcade-platform/sdk/src/lib/channel/channel-manager.ts; the field is
arcade-platform/engine/proto/gamechannel/proto/broadcast.proto). A frame with no from is
keyed by the lowest seat that signed the proof's end state, as before. A forwarder that passes the
sender's bytes through unchanged lets a seat write another seat's address into from, send a proof
that fails, and have every later proof of that honest seat dropped. So deploy a relay that stamps
from before any game it serves vendors SDK 0.22.0 or later, and a custom transport between the
relay and the SDK (your own OffChainBroadcast included) hands over the relay's frame as it came:
it never fills from in itself and never passes on one the sender chose. The game payloads stay
opaque throughout: neither the relay nor the transport reads the state or the proof inside them.
5. Channel manager: the off-chain state machine
One instance per browser session (src/lib/channel/channel-manager.ts). On the hosted path
@xayaarcade/sdk implements this for you and you never write it; standalone, it is yours:
processOnChain(blockHash, height, metadata, reinitState, proof, disputeHeight)
→ bootstraps/updates from GSP on-chain state
→ triggers auto-moves if it's our turn
processOffChain(reinitId, stateProof)
→ processes the opponent's off-chain message
→ validates reinit match + strictly-increasing turnCount before accepting
→ triggers auto-moves in response
processLocalMove(moveBytes)
→ applies the player's own move (or bot's), signs, broadcasts via relay
Critical init order: call processOnChain() before attaching to the relay. The relay's
replay buffer (§4) hands you buffered messages immediately on join: if the channel manager is not
bootstrapped yet, those are silently dropped and the match starts a state behind.
Who sends the closing transaction. When the game ends the winner sends the resolution: never the loser, who has no incentive to spend gas declaring their own loss. Auto-detect it here:
// After auto-moves settle, if the game is DECIDED and we won.
// isFinished() is a separate question from whoseTurn() === NO_TURN, and the
// difference matters here: every finished state answers NO_TURN, but a NO_TURN
// state that is NOT finished is a real, reachable shape. Settle on the former;
// use NO_TURN only to choose resolution-vs-dispute (the GSP rejects a dispute
// naming no on-clock seat). `winner` is the board's own typed query, in the raw
// ABI convention: -1 undecided, -2 draw, else the seat index.
const finished = boardState?.isFinished();
const winner = boardState?.winner();
if (finished && winner === ourIdx) {
txHash = await this.moveSender.sendResolution(this.currentProof);
} else if (opponentIsUnresponsive) {
txHash = await this.moveSender.sendDispute(this.currentProof);
}
One exception: a draw has no winner, so nobody's win path fires: the lowest-index
participant (or, for a game that forwards share weights, the seat they pay most) sends the closing
resolution instead (src/lib/channel/channel-manager.ts),
otherwise the channel hangs open forever. For a wagered game that draw close is also what triggers
settlement: see WAGERING.md.
Duplicate-resolution prevention. A flag stops triggerAutoMoves from firing a second
resolution tx when a pending-transaction check clears too early. It is cleared in exactly ONE
place: when the reinit changes, i.e. a new game on the same channel re-arms it. Clearing it
anywhere else re-opens the duplicate-tx bug; never clearing it blocks the next game's resolution
and locks funds:
private resolutionSent = false;
// in processOnChain, on reinit change: a fresh game re-arms the close:
if (reinitChanged) this.resolutionSent = false;
// in triggerAutoMoves / the win path:
if (finished && winner === ourIdx && !this.resolutionSent) {
const txHash = await this.moveSender.sendResolution(this.currentProof);
if (txHash) this.resolutionSent = true;
}
6. Auto-move re-entrancy
Signing is async, so a naive auto-move loop can re-enter itself while the previous signature is still pending. Guard with a simple lock, and chain inside the lock hold: needed whenever a game's protocol can run multiple auto-moves back-to-back (bot turns, multi-step reveals):
private autoMoveInProgress = false;
private autoMoveRerun = false;
async triggerAutoMoves(): Promise<void> {
if (this.autoMoveInProgress) { this.autoMoveRerun = true; return; }
this.autoMoveInProgress = true;
try {
do {
this.autoMoveRerun = false;
let madeMove = true;
while (madeMove) { // chain auto-moves inside ONE lock hold
madeMove = false;
const move = await this.channel.maybeAutoMove(this.boardState);
if (move) {
await this.applyAndBroadcast(move);
madeMove = true; // loop: check for a chained auto-move
}
}
} while (this.autoMoveRerun); // a state that arrived mid-pass gets its own pass
} finally {
this.autoMoveInProgress = false;
}
}
A call that arrives while a pass runs must set the rerun flag, not just return, as the SDK's
triggerAutoMoves does: without it, a state that arrives while the pass awaits the game
or a move of ours is skipped, and its auto-move, or the close, waits for the next state update,
about a block.
Do not chain by recursing into triggerAutoMoves() from inside the lock: the re-entrant call
sees the lock held and returns immediately, so chained auto-moves silently never run. The loop
form above is what the reference implementation ships
(src/lib/channel/channel-manager.ts). A strictly single-input-per-turn game rarely chains, but
the guard costs nothing and prevents a subtle re-entrancy bug the moment any auto-move path is
added later.
7. Opponent disconnect + resync
The relay emits peer_joined / peer_left (§4) on WebSocket connect/disconnect. Wire these to
the UI and to auto-resync:
broadcast.setPeerCallbacks(
() => { setOpponentDisconnected(false); channelManager.resendCurrentState(); },
() => { setOpponentDisconnected(true); },
);
resendCurrentState() re-broadcasts the current proof on rejoin: the reconnecting peer's
processOffChain() accepts it via the existing monotonic move-count check, so no new protocol
message is needed:
resendCurrentState(): void {
if (!this.currentProof || !this.offChainSender) return;
this.offChainSender.sendNewState(this.reinitId, this.currentProof);
}
Edge cases, all handled by that same monotonic check: both peers reconnect simultaneously (higher count wins), a peer reconnects before the opponent advanced (the resend is not newer, silently dropped), rapid repeated reconnects (idempotent).
8. Proof optimization and client-side verification
Trimming the proof. A naive StateProof accumulates every transition since channel open: for
a long match that is a lot of protobuf messages and an unbounded resolution transaction.
The Arcade SDK's optimizeProof trims as libxayagame's native ExtendStateProof does, to the
shortest proof whose signatures still cover every required seat, which is every participant less
any seat the rules report ejected (arcade-platform/sdk/src/lib/channel/state-proof.ts,
optimizeProof):
- Walk backward from the last transition, the first one included, tracking which participant
signed each
newState. - Stop at the first state, counting back from the end, where the signatures seen so far cover every required seat.
- Promote that state to become the new
initialState. - The transitions after it are kept: they prove the current state from that signed checkpoint forward.
BEFORE (full history):
initialState (reinit, unsigned)
→ t0 (P0 signed) → t1 (P1 signed) → ... → tN (P1 signed)
AFTER optimizeProof (1 transition when two seats alternate, regardless of N):
initialState = t[N-1].newState (already P0-signed)
→ tN (P1 signed) ← current state
Valid because the GSP's proof verification accepts an initialState that is not the reinit state,
as long as every required seat has signed somewhere in the proof, and the initialState's own
signatures count: the trimmed proof still satisfies that. If the walk cannot find every required
seat's signature, it returns the proof untrimmed rather than shipping an unprovable one. Call it
after every extendStateProof(); the SDK's extendStateProof() does this automatically before
returning. How many transitions are kept depends on who signed last, not on how long the match ran:
seats that move in turn keep one fewer transition than there are seats, and a seat that moves
several times in a row adds one transition per extra move. The result can have no transitions at
all, when the last state alone carries every required signature, so check the length before you
read proof.transitions[0]. A proof from the archived reference or from a peer on an older SDK,
which promote the state one step earlier and so keep one more transition, verifies the same way and
is trimmed again when adopted.
Verifying a received proof. Both clients verify every proof received from the peer before applying it: never trust the wire:
- Walk every transition in the proof.
- For each, re-execute the move through the (same) WASM
applyMove()and compare the resulting state hash against the claimednewState. - Verify each signature via
ecrecover, with the Xaya chain sign prefix (SKILL.md §3). Refuse a state carrying more signature entries than the channel has seats, or an entry longer than 65 bytes: that padding credits no one but rides into every proof built on it. - Drop invalid proofs entirely: never partially apply. Surface the event as a divergence indicator in the UI rather than silently ignoring it: a divergence that only shows up as a stalled board is unreportable, and it is your first signal that native and WASM disagree.
Hold a peer's proof without what the referee refuses a whole proof for outside the signed data:
an unknown field on any of its messages, or a set for_testing_version on any of its
states. A peer can add either to an honest state, neither is signed, and every dispute, resolution
and close built on the proof is then refused on chain, so strip them before you hold it. The SDK's
withoutUnsignedFields (exported from @xayaarcade/sdk and /e2e) is that strip, and
processOffChain applies it to a proof offered to it, one saved before a reload included.
A cheater cannot forge state this way: their WASM must produce the identical result as yours, on identical inputs, or the proof is rejected locally before it ever reaches the chain.
9. Untrusted state is a pre-auth consensus DoS: the three-layer IsValid
Before real money - or any adversarial use - rides on a channel game, treat the GSP as a consensus system: every node must compute the same state from the same moves, and must never crash on attacker-crafted input.
This is the single most dangerous surface in the stack. During dispute/resolution the GSP verifies
a StateProof by parsing an attacker-supplied board state and calling IsValid() before any
signature is checked. So IsValid runs on fully attacker-controlled bytes. If a malformed state
passes IsValid and later reaches a CHECK()/abort(), the GSP crashes, and because every node
crashes identically on the same on-chain move, that is a consensus-fatal, pre-auth denial of
service: one cheap move halts the game chain.
Rules (engine/board/board.cpp: a three-layer defence, not one function):
IsValid()rejects every malformed state by returningfalse: never lets later code reach aCHECK/abort.- Compare proto integer fields unsigned (they are
uint32): a value at or above the signed bound must be rejected, not silently sign-flipped negative past a signed comparison. - Bound every value used as an array index (cell indices → per-cell arrays; owner indices → the players vector).
- Guard near-maximum counters against overflow.
- Cap the total count of attacker-sized collections (bombs, fires, entities, …) so proof-iteration cost is bounded.
- Compare proto integer fields unsigned (they are
WhoseTurn()degrades an out-of-range or unvalidated turn toNO_TURN(the move is rejected), neverCHECK/abort.ApplyMove()guards returnfalseon bad input, neverCHECK/abort: it too runs on adversarial pre-signature states.
Gate it. A board-validity test feeds hand-crafted malicious states through your ParseState
(which wraps IsValid) and asserts each is rejected. In the reference implementation that test is
baked into the GSP Docker image build (docker/Dockerfile.xayamand compiles and runs
engine/test/board_validity_test.cpp as a build step), so a failure fails the build and it gates
deploys, not just npm test. Annotate each malicious case with the specific downstream CHECK
it defuses, so a future edit knows what a bound is protecting.
A rules blob on the hosted path has the same problem in a different shape: parse-vs-validate, bound every count, and the fuel/memory/output caps. That version is in ARCADE.md; do not port this C++ advice to a blob unread.
10. The four-service dockerized deploy
Four services on one compose network. Templates ship with this skill:
scripts/docker-compose.template.yml and scripts/env.template, and are the copy-paste
authority for exact flags, ports and build args:
| service | notes |
|---|---|
xayax | Polygon → Xaya bridge, published image. Multi-game host: run one shared XayaX and attach each game's own gsp/relay/frontend to its network. |
gsp | your libxayagame channel daemon, built from your repo (§2) |
relay | the WebSocket relay, one instance per game, not built from your repo. Verify its auth-message format matches your client byte-for-byte before deploying: relay builds differ on the Audience: line (§4) |
frontend | Next.js or anything else; NEXT_PUBLIC_* are baked at build time |
Channel-shaped gotchas, in the order they bite:
- One audience string, two consumers. Feed the relay's
RELAY_AUTH_AUDIENCEand the frontend'sNEXT_PUBLIC_RELAY_AUDIENCEbuild arg from the same compose variable, or client and server drift and every join is rejected (§4). - Declare the build arg. Docker silently ignores an undeclared
--build-arg, so yourDockerfile.frontendmustARG(andENV) everyNEXT_PUBLIC_*the compose file passes. MissNEXT_PUBLIC_RELAY_AUDIENCEand the client quietly falls back to its built-in default audience: a working build that cannot join a channel. NEXT_PUBLIC_*are inlined at build time, so changing one meansdocker compose up -d --build, not a restart. The relay URL is the exception: derive it at runtime fromwindow.locationand the frontend stays portable across hosts.- The GSP's
--game_idis the move namespace and must match the frontend'sNEXT_PUBLIC_GAME_IDand, if you wager, the wager contract's game namespace. Three places, one value. - Bind the GSP and the relay to loopback; expose only the frontend. The relay is an unauthenticated fan-out to anyone who can reach its port until auth is on, and the GSP RPC has no auth at all.
- Wagering needs a fresh GSP datadir or the queue bootstrap silently skips: see WAGERING.md.
Two rules here are general Docker/GSP operations rather than channel-specific: pin the compose network's subnet explicitly (never let Docker auto-pick on a shared host) and give a fresh GSP datadir a Polygon start height just below the current chain head or it never catches up: load the building-persistent-games skill for the full deploy and day-2 operations section.