Pitfalls

The failures this platform actually produces, written so you recognise one in seconds.

Raw markdown →

Pitfalls (consolidated)

Hard-won lessons from building channel games: hosted on an arcade and standalone, and covering the rules blob, the frontend, the real-time layer and wagering. Check this list before debugging anything that feels mysterious.

Format: one row each, symptom → fix, grouped by the part of the stack it bites. A row exists to make you recognise a failure, not to re-teach the mechanism behind it, so where another file in this skill owns a rule, the row states the symptom, the rule in a sentence, and points there. Follow the pointer before you act on the summary.

Row numbers are stable identities, not an order. A row keeps its number for life so that anything citing "row 19" still means row 19; new rows are filed under the heading they belong to and take the next free number, which is why the numbering inside a table jumps. Read the headings, not the sequence.

The Applies to column says which path the row is about:

  • Arcade: a game hosted on an arcade: a rules.wasm blob judged by the shared host GSP, a UI bundle built on @xayaarcade/sdk, handed over for listing (ARCADE.md).
  • Standalone: a channel game you host yourself: your own GSP referee, your own relay, your own frontend (STANDALONE.md).
  • Both: the rule holds either way.

Full context: SKILL.md (the channel protocol), ARCADE.md (hosted path), STANDALONE.md (self-hosted referee), WASM.md (rules blob + WASM toolchain), WAGERING.md, EXAMPLES.md. On-chain and GSP-operations pitfalls: Docker networking, XayaX flags, SQLite WAL growth, ZMQ staleness wedges: are deliberately not repeated here; load the building-persistent-games skill for that list.

Arcade

#Applies toPitfallFix
1ArcadeBundle built without NEXT_PUBLIC_GAME_IDPasses pre-flight, gets registered: then refuses to boot: a clean refusal screen, zero console errors. The bundle must be built with the arcade's shared move namespace, never your own game key. scripts/build-export.sh refuses a --bundle build without it, which is where you want to find out. ARCADE.md §3 step 4 gives the value and the build line; §4 owns the gameId-vs-moveNamespace rule behind it.
3Arcadeblob-fresh fails and you go looking for a stale-timestamp causeThere is no timestamp in it - the target checks presence, the sha256 against the committed sidecar, and check-blob.sh's structural gate, and mtimes are deliberately never consulted (blob/tests/Makefile). So a red blob-fresh means the blob is missing, does not match its sidecar, or is structurally wrong: rebuild with blob/build-blob.sh, and never "fix" it by rewriting the sidecar. WASM.md §A2 owns the sidecar rule.
5Arcadecfg suffix omitted in the submissionA blob that expects cfg bytes wedges every channel part-filled. Your fork's value lives in blob/MANIFEST.md; pre-flight forces an explicit none-or-hex answer, making an omitted cfgSuffix an error rather than a silent empty one. The suffix also gates the seed: the host prefixes LE32(channel seed) only when the registered cfgSuffix is non-empty (blob/MANIFEST.md), so "no suffix" also means "no per-channel seed". The inverse is as silent: a suffix registered for a blob that wants none makes the host prefix LE32(seed), the blob refuses the length, and channels wedge the same way: submit cfgSuffix: null and have the blob reject every non-zero length (ARCADE.md §3 step 2).
6ArcadeReact Compiler freezes "animations"Game frontends build with reactCompiler: true (next.config.ts): any impure read in render scope (Date.now, Math.random, a direct store read) gets argument-keyed-memoized and silently freezes. Drive animation from a requestAnimationFrame loop reading refs, and verify against the BUILT bundle - the bug is invisible to jsdom tests.
7ArcadeAssuming the ABI provides randomness or a clockIt provides neither - state bytes and move bytes only. Unpredictability must come from player move payloads under a commit-reveal discipline; per-channel variety can come from the channel-seed prefix of cfg (blob/MANIFEST.md, and row 5).
8ArcadeBuilding on xaya/xayaman (no arcade- prefix)That is the FROZEN pre-split monorepo, kept only as a reference implementation. The copy-me template is xaya/arcade-xayaman; fork that one.
9ArcadeA hand-written root-absolute URL 404s once the game is mountedA --bundle build bakes the __arcade_base__ placeholder and registration rewrites only that token to your real mount, so anything Next does not own - a literal src="/…", a CSS url(/…), any root-absolute URL you assemble at runtime - escapes the rewrite and 404s under the mount and nowhere else. Keep every asset reference relative or route it through Next. ARCADE.md §5 owns the base-path rule, the escape list and the pre-flight token check.
58BothA game that hashes in the browser works on the deployed arcade and "freezes" on a LAN IP or a plain-HTTP hostcrypto.subtle exists only in a secure context - HTTPS or localhost - and is undefined over a LAN IP, so every call rejects and the symptom is a game that stops advancing rather than an error anyone sees. The SDK does not have this problem: it uses @noble/hashes for exactly this reason (sdk/src/lib/crypto/sha256.ts). Your own code might. Either import the SDK's sha256 from @xayaarcade/sdk/core, or ship a synchronous implementation of your own, or - if you genuinely need Web Crypto - gate at boot with a message naming the fix, never let it fail per-frame. ARCADE.md §3 step 3 owns the rule and names the shipped games that import the SDK's.
59ArcadeA 3- or 4-seat game quietly settles winner-take-allNot exporting arcade_share_weights is not "no opinion", it is an opinion: the host falls back to plain winner/draw, and above two seats a finished terminal naming no executable payout does not even settle: it clears the dispute and leaves the pot where it is. Decide it deliberately; ARCADE.md §6 owns which games export weights, the wire format, the fallback and three worked vectors including winner-take-all with a split draw.
68ArcadeTreating a game path as an origin boundaryShipped planes separate the shell and games origins. Games on the same games origin still share DOM and storage privileges with one another. The optional GAMES_SAME_ORIGIN_FIRST_PARTY=1 override also removes shell isolation. Read the iframe source and config; use your own storage prefix. ARCADE.md §5 describes both shapes.
70ArcadeUI gated on "the player is in a wallet prompt right now"A player can arm a Smooth Play session key in the arcade, and the shell then signs eligible moves with it locally: the move comes back confirmed in less time than a human takes, with no prompt ever shown. The grant also lapses, so the prompt can return mid-match. Treat the move-result as the only signal, in both directions. The key is the shell's and never reaches your frame; there is nothing to integrate. ARCADE.md §3 step 3 owns the three rules.
71ArcadeA player timed out of the match is still drawn as a live opponentThe GSP never edits the participant list, so the roster outlives an ejection and the board JSON has no notion of one. Read ejectedSeats off the channel store for anything drawn about the players rather than about the position: empty means nothing known, not nobody ejected. ARCADE.md §3 step 3 lists it beside matchOver, linkDown and opponentDisconnected.
72ArcadeA renderer that counts board updates loses moves after a rejoinThe published state is cumulative and only the latest is delivered: a client rejoining a live match replays its whole proof backlog in one synchronous run, and the intermediate states are coalesced away because none of them could ever have been painted. Anything derived from watching updates go by - a per-update move counter, an animation queued per delivery - silently skips the burst. Read what the current state says. ARCADE.md §3 step 3 owns the contract.
73ArcadeAfter opting into presentation.squareBoard the canvas sizes itself to nothingThe SDK renders the board frame in place of your wrapper, so the element your ResizeObserver or getBoundingClientRect was measuring no longer exists, and an observer on a vanished node reports nothing rather than failing loudly. Observe the surface (class="arcade-board-surface") or the canvas instead, and return the board CONTENT rather than your own frame. ARCADE.md §3 step 3 owns the migration order.
74Arcadearcade_winner sentinels the wrong way round-1 is undecided, -2 is draw, 0..N-1 the winning seat (wasm_judge.hpp), and a "draw" label in the UI proves nothing about the judge: cover both sentinels and a winning seat in the blob battery and the signed-peer closure test. WASM.md's ABI table owns the signatures; what a draw does at settlement - the designated closer (SKILL.md §5), the share-weight fallback above two seats (ARCADE.md §6, row 59) - is decided there, not by the label.
90ArcadeThe notices above a declared board sit over the canvas or the HUD instead of above itThe stage or HUD is position: fixed, so it is placed against the frame's viewport and escapes the box the SDK lays the board out in, which gives up its room to the notices while they stand. Give the game a root that fills its box, position the stage and HUD absolute inside it, and size the renderer from its container with a ResizeObserver. ARCADE.md §3 step 3 owns the rule and the two things the switch changes ("Keep the stage and the HUD inside the game's own root").
91ArcadeA HUD at the board's top runs under the status line on a phone, or sits below an empty band while a notice standsIts top inset is a hard-coded height, but the bar wraps to a second row on a narrow frame. Read var(--arcade-status-inset, <its own>): the SDK sets it from the measured bar, and to 0px inside arcade-board-area while a notice stands. A jsdom test sees only the fallback, so check the bar's room on the real page at phone size. ARCADE.md §3 step 3 owns the rule ("The top bar's room is --arcade-status-inset").

Frontend & build

#Applies toPitfallFix
10BothNEXT_PUBLIC_* env vars are embedded at build timeRebuild (not restart) after changing them; use the real server address, not localhost, for anything remote.
11BothBigInt literals (0n) fail to compileSet tsconfig.json target to ES2020 or later.
12StandaloneStubbed / timeout-based tx confirmationUse a real waitForTransactionReceipt (@wagmi/core) - a stub makes the UI believe a failed tx succeeded. (Arcade games never send transactions themselves; the shell signs and returns a tx hash over the bridge.)
13BothTesting against a stale production bundleVite/Next dev servers hot-reload; a dist/ / .next production build does not - always rebuild after code changes before testing it.
88BothA test or comment names a chunk-*.js file in the vendored SDK, or a line in itThe bundler content-hashes those names, so they change whenever a chunk's content changes, which most SDK releases do, and a version bump that changes them turns every such reference stale without any test failing. Name the exported function or the source file under sdk/src/ instead.
89BothOne class in a template literal never reaches the stylesheet while the classes beside it doTailwind v4, which the template builds with, finds classes by scanning the source as text, and reads a class written straight against a following ${ as part of a longer token, so `grid place-items-center${wide ? ' gap-4' : ''}` emits no place-items-center. Nothing fails: the class is missing only where nothing else in the scanned files uses it. Put a space before each ${ (`grid place-items-center ${wide ? 'gap-4' : ''}`). The SDK's own components are held to this by a test (sdk/tests/components/class-templates.test.ts), since a game's Tailwind scans the built SDK the same way (ARCADE.md §3 step 3, "The SDK's CSS reaches your build").

Channel protocol

#Applies toPitfallFix
14StandaloneState-proof signature verification fails for every proof (CRITICAL)Off-chain state-proof signatures are not plain personal-sign messages: they must carry the chain-scoped Xaya message prefix before signing, because the GSP's verifymessage call (via XayaX) adds that prefix when it verifies. The exact prefix string, byte for byte, is in SKILL.md §3: copy it from there. On the Arcade the SDK does this for you.
15BothDispute rejected for a finished gameWrong move for the situation: dispute is only for an active game whose opponent went offline, and a finished game closes with a resolution. SKILL.md §5 owns the two moves and their effects.
16BothLoser never closes the channelNobody volunteers to pay gas to declare their own loss, so the winner must auto-send the resolution, and a draw, having no winner, needs its own designated closer or the channel hangs open forever. SKILL.md §5 owns both branches.
17StandaloneprocessOnChain() runs after the relay connectIt must run before connecting to the relay, or early on-chain state races connection setup.
18BothInventing a second progress counter beside turnCountThere is exactly ONE, and it is the blob's arcade_turn_count: strictly higher after every applyMove (the referee accepts a resolution, or a dispute while one is already open, only with a count above the on-chain state's, so a move that keeps it cannot answer a dispute), and never lower across a dispute-timeout reinit (docs/ARCADE-ABI.md §2). A second counter, even one written as a bare passthrough to the first, has only one possible future: two counters disagreeing, silently, in the comparison that decides who wins a dispute. If your rules' natural turn number does not rise on every move, or drops between phases, that is a bug in what you export as arcade_turn_count, not a reason for a second number: the referee orders states by that one value and a client ordering by anything else loses disputes it should win. A test harness catches a blob that keeps the count: wrap its BoardRules in withRisingTurnCount(rules) from @xayaarcade/sdk/e2e and applyMove throws on such a move, which ChannelManager would only drop as superseded (sdk/src/lib/channel/rising-turn-count.ts; a state class with #private fields is not supported).
19BothAuto-move feedback loop makes both players' AI run ~5× too fastIf maybeAutoMove() - the ONLY method on OpenChannel (sdk/src/lib/channel/open-channel.ts) - submits an idle/no-op move when no input is pending, both sides auto-submit in a tight loop. Return null when pendingInput is null: that rate-limits to the net-tick interval.
20StandaloneRelay grows game logic over timeKeep the relay a dumb forwarder: one universal image for every game, configured only by env vars (GSP_URL, RELAY_AUTH / audience). Never add throttling, tick rates or state tracking to it: the real-time feel comes from client-side local sim, not relay intelligence.
21StandaloneP0's first proof is lostIf P0 moves before P1 connects to the relay, P0's proofs go nowhere. Re-broadcast the current proof on peer_joined.
22StandaloneGame "restarts from scratch" on rejoinA reconnecting player's opponent must resend their current proof on peer_joined (the receiving side's monotonic move-count check already accepts only newer proofs).
23StandaloneDuplicate resolution tx fires repeatedly after a winClearing pendingPutState after a tx mines lets triggerAutoMoves() re-fire another resolution. Guard the win/draw close on !resolutionSent and latch resolutionSent = true only after putStateOnChain() returns a tx hash: latching before the send would permanently suppress the resolution the first time a send fails on gas or nonce. Clear it in exactly ONE place, on reinit change (a new game on the same channel), never anywhere else.
24StandaloneEvery relay join rejected (signature mismatch) despite correct session keysThe client must sign the auth string your relay build recovers, byte for byte, including the audience, which is a signed protocol constant and not a display label, so a "cosmetic" rename fails every handshake closed with no partial-credit failure mode. Read the message format out of STANDALONE.md §4, never out of memory, and check it against your relay's own auth.ts.
85BothA game's useInput hook stops submitting while a peer is off the relayA hook that returned early on linkDown or opponentDisconnected signed nothing on its turn, so that peer could leave the relay, dispute, and win the match: a move is signed and queued, and an answer to a dispute goes on chain, whether or not the peer is listening. The SDK gates no submission on either. Pause the picture, never the input (GameAdapter.useInput in sdk/src/lib/games/adapter.ts; ARCADE.md, the stall clock).

Real-time local sim

#Applies toPitfallFix
25BothAction/racing channel game "freezes and rewinds"A turn-based judge protocol only advances visually on a full proof round-trip. Add a client-side local-sim layer that predicts every tick and snaps to proofs as they arrive: the protocol stays turn-based, only the frontend changes.
26BothA time-limited effect (e.g. an oil slick) expires ~10 s earlyIf the state→JSON conversion omits a "dropped at tick" field and you default it to 0, the local sim computes tick - 0 > LIFETIME almost immediately. Backfill the field from the proof's current gameTick when converting.
27BothAI-controlled entities can "win" a race/round meant for humansThe referee checks an isPlayer flag; if the TypeScript-side finish check doesn't also gate on it, AI entities trigger a human's win condition. Add isPlayer && to the client check, and verify the board-state converter maps isPlayer for BOTH seats, not just the local player's.
28BothSluggish gameplay on high-latency connectionsMake the net tick rate configurable (env var) and recompute ticks-per-net-tick from it, so game speed stays constant across rates.
29BothVisible snapping between positions at low tick ratesUse exponential smoothing plus velocity extrapolation in the render loop, and allow the interpolation alpha to exceed 1.0 (up to ~2.0) so entities extrapolate past the last known tick, then correct smoothly on the next proof.

WASM build

Golden traces (blob/tests/packed_trace*.json) are generated outputs, never hand-edited: WASM.md §A4 is the authoring procedure.

#Applies toPitfallFix
30BothBuild container runs as rootAlways drop to non-root; use an entrypoint with gosu to fix volume permissions first (the template's blob test harness runs docker run -u "$(id -u):$(id -g)" for the same reason).
31Standaloneprotoc --cpp_out produces double-nested output pathsprotoc places output relative to the proto's path under -I; don't add an extra subdirectory in --cpp_out on top of that: let protoc create the subdirs.
32StandaloneBrowser serves a cached WASM JS glue file against a new .wasm binarySymptoms: Import #0 "env": module is not an object or function, or "function import requires a callable". Cache-bust the <script> src with ?v=<timestamp>. (An arcade rules blob has no glue file at all: it is zero-import and goes straight through WebAssembly.instantiate, sdk/src/lib/wasm/packed-judge.ts.)
33StandaloneMissing gamechannel/proto/broadcast.protoInclude it in BOTH the protoc generation step AND the build's proto source list - broadcast.cpp needs it for the full gamechannel build.
34StandaloneBuilding the WASM layer from the wrong libxayagame commitWASM support (wasm/XayaGameWasmConfig.cmake.in, installed as the XayaGameWasm CMake package - there is no XayaWasm.cmake and no special wasm branch) ships on master. Check out the SAME pinned commit your native GSP builds from: a branch tip is a different commit, which means native/WASM drift, the consensus-fatal kind.
35BothAssuming the board can advance one tick per player inputIt is built around the turn-based protocol (P0's input is stored as pending, P1's submission applies both). Local prediction is a separate TypeScript mirror of the physics - integer-for-integer, never a float re-derivation - pinned to the blob's golden trace by a test, and reconciled by loading each authoritative proof and replaying the local inputs issued after its tick (SKILL.md §6).
36BothAssuming players see each other's input mid-tick in simultaneous gamesThe board applies BOTH players' inputs together across a full net-tick's worth of ticks; P0's input is held as pending_input until P1 submits, which is what keeps it fair despite the sequential wire protocol.

Fixed-point & determinism

The law itself, in one sentence: consensus-relevant code must be a pure function of the ordered move sequence - no wall-clock, no external I/O, no unseeded RNG - so every node computes byte-identical state; load the building-persistent-games skill for the full section. The native-vs-WASM gates that prove it for a channel game are in WASM.md.

#Applies toPitfallFix
37BothfxDiv overflows on tiny denominators(int64_t)a << 16 can dwarf a very small divisor and overflow int32_t. Use a larger epsilon floor (e.g. a minimum direction magnitude), not a near-zero one.
38BothCORDIC produces garbage from unclamped anglesExtreme un-range-checked yaw/pitch input (e.g. INT32_MAX) breaks the algorithm. Clamp angles to [-2π, 2π] in the board rules before physics sees them.
39BothWrong fixed-point format for the coordinate spaceQ16.16 (~0.000015 precision, ±32K range) suits small arenas; Q19.12 (coarser precision, ±512K range) suits long racing tracks. Pick by required integer range, not habit.
40BothConfusing an int×fixed multiply for fxMulTICKS_PER_NET * TICK_DT (a plain int count times a fixed_t) is ordinary multiplication, not fxMul - fxMul also works, but it reads as if both operands were fixed-point, which they aren't.
41BothFloat creeping into consensus-relevant codeFloat is allowed ONLY at the JS boundary - the fixed_t ↔ float conversion for JSON/JS in a standalone Embind build. All internal game logic stays in fixed_t. An arcade rules blob has no float boundary at all: it exchanges packed bytes.

FPS-specific

#Applies toPitfallFix
42BothPlayer pushed sideways by the floorA ground collision AABB must be flagged ground=true so the collision system resolves it on the Y-axis only.
43BothThe judge can't parse BSP map files at runtime like the client doesExtract collision data to a C header at build time (a one-off tool script) and compile it into the rules binary, so client and judge share identical AABB sets.
44BothStatic world-geometry array too small for the mapSize MAX_WORLD_BOXES with headroom over the actual brush count and check it at load time: undersizing silently drops collision geometry.

Wagering (standalone V5)

On the Arcade, wagering is a platform module: your rules and UI stay wager-free and an operator enables it: see WAGERING.md §1. The rows below are for a standalone channel game that hand-rolls its own wager contract and payment queue; WAGERING.md owns each mechanism, and these rows exist only to make the failure recognisable.

#Applies toPitfallFix
45StandaloneAdmin authorization mistaken for a p/-name checkThere is no admin p/ name in V5 and no name-equality check: the contract owns the game name, is trusted through NFT ownership, and its admin moves never appear in the player move list at all, so code hunting for them there finds nothing. An earlier design authorised admin moves through a p/ admin name; V5 does not. WAGERING.md §5 owns the model.
46StandaloneNew queue entry uses the paid matchIdPermanently bricks that payee: the contract's paymentMade[...] guard is stuck true for that id. On channel close the new queue entry's match_id must be the channelId hex, never the original payment's matchId.
47StandalonePayment-queue address comparison failsQueue payee and wallet addresses are compared lowercased (the GSP's NormalizeAddress() lowercases them at parse and seed time), so an EIP-55 checksummed spelling of one will not match. This covers the pay.addr and w0/w1 fields only: session signing addresses take the OPPOSITE rule (row 66).
48StandalonePayout sent to the wrong address typePayouts go to w0/w1 (wallet addresses), never a0/a1 (ephemeral session signing keys).
49StandaloneMiscounting closing braces when hand-building the admin move JSONThe free and paid tiers close a different number of braces, because only the paid one carries the nested payment object, so a count carried over from the other tier emits malformed JSON, and the admin move never lands. Build the string from WAGERING.md §6's two exact forms instead of counting by eye; brace counts quoted from a prior contract version are wrong.
50StandaloneQueue bootstrap silently does nothingIt seeds only a genuinely fresh GSP datadir: reusing an existing one skips it, with no second chance and no error. WAGERING.md §9 owns the flags and the matchId rule.
51StandaloneContract / GSP / frontend disagree on which game they are wagering forThree knobs must match exactly: the contract's gameNamespace() == the GSP's --game_id == the frontend's NEXT_PUBLIC_GAME_ID.
52StandaloneRegistering the canonical game name to the wager contract "just for testing"Registering g/<name> to a contract is permanent: never releasable. Use a throwaway game id for betas and keep the real name free.
53StandaloneReal-funds settlement test asserts on wallet balance deltasConfounded when the operator address equals the funded/deployer address. Assert on the contract's MatchJoined event fields (queuePayee / winnerPayout; contracts/src/XayaPaymentQueueV5.sol) instead.
64StandaloneThe contract's replay marker is keyed without the payout amountpaymentMade[bet][matchId][payee] is safe only while the contract computes the one payout itself. As soon as a caller can state per-entry amounts, a payment at a made-up amount marks the true queue front paid forever on the contract while the GSP - which keys prepaid on the amount - still owes it: no later honest start can pay that entry, and every honest paid start at that combo afterwards forfeits its stakes with no channel and no refund. Key both markers on the same tuple, amount included. WAGERING.md §3 route 5 owns it.
65StandaloneJoin consent checked at the join and never again at the fillA joiner consents to the queue entry they read. If the queue can move before the match fills - another lobby consuming the front, or N seats filling across several transactions - the fill pays a group nobody agreed to, the GSP refuses a start that does not match its own front, and the stake is gone with no channel and no refund. Re-check the consented entry at the fill and revert instead of paying. WAGERING.md §4 owns the rule.
66StandaloneSession signing address sent in any spelling but EIP-55The a0/a1 seat addresses are stored verbatim and the referee compares a recovered signer against them character for character, so only the canonical EIP-55 checksummed spelling names a seat whose moves can ever be proved. The contract refuses any other spelling at create/join, and the GSP rejects a start whose seat is non-canonical (a paid one that matched the queue front is disposed and its stakes refunded, not stranded, but the lobby still never opens). This is the opposite of the queue payee rule (row 47): payees are lowercased, seats are checksummed. Derive a0/a1 with a checksumming address helper, never toLowerCase().

Headless drivers & Node (bots, scripts, e2e harnesses)

For anything that drives a channel from Node with the SDK's headless entry point (@xayaarcade/sdk/e2e) instead of the stock browser client. The browser client is not exposed to most of these.

#Applies toPitfallFix
75BothThe relay is down when you connect and the driver never tries againUnder Node 22's built-in WebSocket a connection refused at the TCP level fires error and never close; WsBroadcast queues one reconnect for a socket's error and its close together, and connect() rejects when the relay closes the socket before acking the join (a capacity or signature refusal; the relay owns the close codes) (sdk/src/lib/relay/ws-broadcast.ts). Treat a rejected connect() as a failed attempt: back off and fall back to the next relay on the served list after a couple of failures in a row.
76ArcadewaitForTransactionReceipt throws "receipt not found" for a transaction that minedThe shell's read service behind /api/polygon-rpc caches a null receipt for a short while, so viem's wait, which asks again straight away after seeing the new block, gets the cached null and throws TransactionReceiptNotFoundError. The transaction is fine. Treat that error as "poll again", never as a failed send, and check by polling the receipt yourself before you retry or report a failure.
77BothA disputer's client answers its own disputeThe automatic resolution goes out only from the seat the dispute puts on the clock, as in libxayagame's manager (sdk/src/lib/channel/channel-manager.ts); the Resolve Dispute button (fileDispute) does not check that.
78BothA second resolution is paid while the first is still pendingNo automatic resolution or close goes out while an answer of ours is on its way, however it was put on chain (a resolution, or a state submission newer than the disputed state, pending or stopped pending less than SUBMISSION_VIEW_LAG_BLOCKS ago) (channel-manager.ts); getDispute() reports resolutionSettledAt, and ChannelManager.isDisputeAnswerOutstanding() holds the Resolve Dispute button the same way. The holds last as long as your TransactionSender.isPending answers true, so a custom sender that can see the chain view should stay pending until it reaches the mined block + SUBMISSION_VIEW_LAG_BLOCKS, and PolygonSender stops counting a move as pending after PENDING_TIMEOUT_MS with no receipt (sdk/src/lib/chain/polygon-sender.ts).
79BothA resolution is sent twice after it minedThe GSP view lags the chain by a block or two and still shows the dispute after the receipt arrives; the hold in row 78 keeps the same proof from going out again (channel-manager.ts).
80BothAn un-awaited call kills the Node processThe channel manager starts bootstrap, the pending-transaction check and the auto-resolver without awaiting them, and logs what a rules module or a TransactionSender throws on those paths (channel-manager.ts); from fileDispute and putStateOnChain a throw reaches the caller. Make every method of your sender, and your rules adapter's parseState, catch internally and return a failure value (the TransactionSender doc says its methods should not throw but for the decline).
83BothA large close is mined as a failure in the e2e senderOnChainMoves.send uses GAS_MARGIN_PERCENT of the node's estimate (a quarter more), or GAS_LIMIT_FALLBACK when the node will not estimate (both in sdk/src/e2e/index.ts), and a move that reverts counts its gas in spentWei. A failed send resolves null, with the cause in lastError and a console.warn, so check lastError. PolygonTxSender takes three arguments (the OnChainMoves, the name and the kind) and sends a proof compressed whenever that is smaller and within the compressed size bound.
84BothA signer address compared with a lowercase string never matchesThe referee compares recovered signers as exact checksummed strings (row 66), so PrivateKeySigner returns getAddress() checksummed (EIP-55) and throws on an account whose address is not one, and buildCreateMove and buildJoinMove checksum the signing address they are given and throw on a non-address (a mixed-case one with a wrong checksum included) (sdk/src/lib/crypto/eth-signatures.ts; seatAddress in sdk/src/lib/chain/move-builder.ts). Lowercase before you compare it case-sensitively with a lowercase string (the SDK itself compares lowercased), and do not pass a placeholder string as a test address.
86ArcadeA test or driver waits at an agreed free match and nothing ever startsA free agreed create or join goes out only after one press of "Start the match" on the agreed-match banner, and a handoff whose ts is too old or too far ahead of the clock is dropped, so a fixture with a fixed old ts (such as 1_700_000_000_000) shows the ordinary lobby and no banner. Hand off a current ts and press the button; a staked step is unchanged. ARCADE.md §3 step 3a owns the rule.
87BothA mocked GSP answers getchannel with no channel and no up-to-date status, height or block hash, and the board treats it as a read failuregetChannel and getChannelWithHeight answer null only from a GSP whose status is up-to-date and which gives the height and hash of the block it read at (getChannelWithHeight returns that hash as blockHash, which is what tells a later read whether the block is still the chain's); a GSP still catching up may not have reached the block that opened the channel, so a null from one throws, as a failed read does, and so does a channel that does not decode (sdk/src/lib/chain/gsp-client.ts). Give a mock that means "channel gone" an up-to-date state, a numeric height and a blockhash of 64 hex digits with no 0x; a test that waits for that close to be final also needs an earlier getchannel answer that has the channel, at a lower height than the first answer without it and with a blockhash of the same 64-hex form, and the chain's RPC to give the blocks at both heights the same hashes (stillOnChain and markChannelGone in sdk/src/lib/channel/channel-record.ts).
92BothA mock GSP drops disputes and resolutions, so a finished game never closesThe SDK sends a dispute's or resolution's proof z1: compressed whenever that is smaller (encodeProof), so a mock that reads the proof as base64 protobuf alone reads nothing and the move never lands. Read it with the SDK's decodeProof (exported from . and ./e2e), which takes both forms (sdk/src/lib/channel/proof-encoding.ts).

Consensus safety

#Applies toPitfallFix
54BothIsValid() treated as a normal validation functionIt runs on untrusted, attacker-controlled board states during dispute/resolution verification, before any signature is checked, so a reachable CHECK/abort() inside it is a pre-auth, consensus-fatal DoS: one cheap move crashes every node identically and halts the chain. Malformed input must be rejected, never crashed on. STANDALONE.md §9 owns the three-layer C++ defence; ARCADE.md carries the blob-shaped version, where arcade_is_valid is the same surface.
55BothA dispute-timeout close never sets the board's finished flagThe referee closes the channel without ever advancing the state to a finished position, so a UI that gates "you won" on a finished flag hangs forever ("I disputed, it stayed open"). Derive that the match ended from the channel leaving the referee's open set instead, and do not derive who won from the last dispute's whoseTurn: on the Arcade the blob decides a timeout outcome at every seat count, so that mirror can tell the loser they won. SKILL.md §5 owns both halves. Separately, what the player sees: the close recap holds until they press Back to Lobby, paid or free, and the player who forfeits gets that screen too. A game rendering ChannelGame gets both free; a custom shell leaves through the button.
61BothA TypeScript mirror of a consensus hash drifts from the blob, and the drift is a forkAny preimage you build twice - once in C++ for the judge, once in TS for the client - is a consensus bug waiting for one of the two to be edited. Two shipped disciplines: pin both against a golden-vector file so a drift is a red test (arcade-dungeonchannel/src/lib/dungeon/golden-vectors.json), or delete the TS copy outright and build every signed move by calling a blob export, so there is exactly one spelling of the wire format (arcade-dungeonchannel/src/lib/dungeon/secret.ts documents its own deletion and why; the golden file pins the few folds the blob does not export). Unused mirrored consensus crypto is the worst case: nothing exercises it, so it rots quietly and is then trusted by whoever finds it next. COMMIT-REVEAL.md and HIDDEN-INFORMATION.md own the two patterns.
62BothTurn-interleaving used for a game where the choice itself is the secretP1 can read P0's stored pending_input out of the state before choosing, because every participant holds the full signed state. The pattern is fair only when the second submitter gains nothing from seeing the first: a physics batch, not a bid or a thrown attack. Use a commitment instead: COMMIT-REVEAL.md owns the protocol, including what the rules must do when a seat commits and never reveals. SKILL.md §4 owns the limit itself.
63BothA commitment scheme where refusing to reveal is a safe moveA commit-reveal protocol is only sound if non-reveal is punished, and that is a rules decision nobody makes for you. Write the payoff table down explicitly before you implement: what a silent seat forfeits, what a false accusation costs the accuser, and what the timeout path pays out. HIDDEN-INFORMATION.md §7 and COMMIT-REVEAL.md §7 own the shipped answers: a stonewaller that cannot satisfy the audit routes to arcade_resolve_timeout and forfeits, a seat that will not open its fleet or coin seed loses on the clock, and a timeout abandons the in-flight pass instead of replaying it.
56BothAssuming one test command covers determinismIt is always two gates, run manually before you ship. Standalone: an engine pure-sim gate (native g++ vs emscripten) and a full proto-adapter gate (native vs the prebuilt WASM binary): neither runs under npm test, and the GSP's Docker build bakes in only the board-validity and GSP-logic tests. Arcade: blob/tests/run-tests.sh's packed-determinism target (native == wasmtime on the real blob, every cfg vector) plus npm run e2e:determinism.
57StandaloneA low-height fork resolves every open dispute instantlyThe expiry pass subtracts the fixed dispute window from the current height, so on an unsigned height below that window the subtraction underflows and every dispute looks already expired, which is exactly the state a fresh forked test chain starts in. STANDALONE.md §2 names the constant and gives the guard.

The citations above point into arcade-platform/{sdk/src/, engine/judge/, docs/} and arcade-xayaman/{scripts/build-export.sh, next.config.ts, blob/MANIFEST.md, blob/build-blob.sh, blob/check-blob.sh, blob/tests/Makefile, blob/tests/run-tests.sh}; rows about libxayagame or your own project name the files they mean. Rows that point at a sibling file instead of a source file do so because that file is where the mechanism is maintained: read it there, not from a summary here.