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.wasmblob 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 to | Pitfall | Fix |
|---|---|---|---|
| 1 | Arcade | Bundle built without NEXT_PUBLIC_GAME_ID | Passes 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. |
| 3 | Arcade | blob-fresh fails and you go looking for a stale-timestamp cause | There 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. |
| 5 | Arcade | cfg suffix omitted in the submission | A 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). |
| 6 | Arcade | React 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. |
| 7 | Arcade | Assuming the ABI provides randomness or a clock | It 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). |
| 8 | Arcade | Building 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. |
| 9 | Arcade | A hand-written root-absolute URL 404s once the game is mounted | A --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. |
| 58 | Both | A game that hashes in the browser works on the deployed arcade and "freezes" on a LAN IP or a plain-HTTP host | crypto.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. |
| 59 | Arcade | A 3- or 4-seat game quietly settles winner-take-all | Not 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. |
| 68 | Arcade | Treating a game path as an origin boundary | Shipped 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. |
| 70 | Arcade | UI 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. |
| 71 | Arcade | A player timed out of the match is still drawn as a live opponent | The 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. |
| 72 | Arcade | A renderer that counts board updates loses moves after a rejoin | The 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. |
| 73 | Arcade | After opting into presentation.squareBoard the canvas sizes itself to nothing | The 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. |
| 74 | Arcade | arcade_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. |
| 90 | Arcade | The notices above a declared board sit over the canvas or the HUD instead of above it | The 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"). |
| 91 | Arcade | A HUD at the board's top runs under the status line on a phone, or sits below an empty band while a notice stands | Its 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 to | Pitfall | Fix |
|---|---|---|---|
| 10 | Both | NEXT_PUBLIC_* env vars are embedded at build time | Rebuild (not restart) after changing them; use the real server address, not localhost, for anything remote. |
| 11 | Both | BigInt literals (0n) fail to compile | Set tsconfig.json target to ES2020 or later. |
| 12 | Standalone | Stubbed / timeout-based tx confirmation | Use 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.) |
| 13 | Both | Testing against a stale production bundle | Vite/Next dev servers hot-reload; a dist/ / .next production build does not - always rebuild after code changes before testing it. |
| 88 | Both | A test or comment names a chunk-*.js file in the vendored SDK, or a line in it | The 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. |
| 89 | Both | One class in a template literal never reaches the stylesheet while the classes beside it do | Tailwind 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 to | Pitfall | Fix |
|---|---|---|---|
| 14 | Standalone | State-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. |
| 15 | Both | Dispute rejected for a finished game | Wrong 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. |
| 16 | Both | Loser never closes the channel | Nobody 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. |
| 17 | Standalone | processOnChain() runs after the relay connect | It must run before connecting to the relay, or early on-chain state races connection setup. |
| 18 | Both | Inventing a second progress counter beside turnCount | There 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). |
| 19 | Both | Auto-move feedback loop makes both players' AI run ~5× too fast | If 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. |
| 20 | Standalone | Relay grows game logic over time | Keep 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. |
| 21 | Standalone | P0's first proof is lost | If P0 moves before P1 connects to the relay, P0's proofs go nowhere. Re-broadcast the current proof on peer_joined. |
| 22 | Standalone | Game "restarts from scratch" on rejoin | A 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). |
| 23 | Standalone | Duplicate resolution tx fires repeatedly after a win | Clearing 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. |
| 24 | Standalone | Every relay join rejected (signature mismatch) despite correct session keys | The 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. |
| 85 | Both | A game's useInput hook stops submitting while a peer is off the relay | A 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 to | Pitfall | Fix |
|---|---|---|---|
| 25 | Both | Action/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. |
| 26 | Both | A time-limited effect (e.g. an oil slick) expires ~10 s early | If 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. |
| 27 | Both | AI-controlled entities can "win" a race/round meant for humans | The 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. |
| 28 | Both | Sluggish gameplay on high-latency connections | Make the net tick rate configurable (env var) and recompute ticks-per-net-tick from it, so game speed stays constant across rates. |
| 29 | Both | Visible snapping between positions at low tick rates | Use 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 to | Pitfall | Fix |
|---|---|---|---|
| 30 | Both | Build container runs as root | Always 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). |
| 31 | Standalone | protoc --cpp_out produces double-nested output paths | protoc 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. |
| 32 | Standalone | Browser serves a cached WASM JS glue file against a new .wasm binary | Symptoms: 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.) |
| 33 | Standalone | Missing gamechannel/proto/broadcast.proto | Include it in BOTH the protoc generation step AND the build's proto source list - broadcast.cpp needs it for the full gamechannel build. |
| 34 | Standalone | Building the WASM layer from the wrong libxayagame commit | WASM 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. |
| 35 | Both | Assuming the board can advance one tick per player input | It 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). |
| 36 | Both | Assuming players see each other's input mid-tick in simultaneous games | The 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 to | Pitfall | Fix |
|---|---|---|---|
| 37 | Both | fxDiv 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. |
| 38 | Both | CORDIC produces garbage from unclamped angles | Extreme 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. |
| 39 | Both | Wrong fixed-point format for the coordinate space | Q16.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. |
| 40 | Both | Confusing an int×fixed multiply for fxMul | TICKS_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. |
| 41 | Both | Float creeping into consensus-relevant code | Float 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 to | Pitfall | Fix |
|---|---|---|---|
| 42 | Both | Player pushed sideways by the floor | A ground collision AABB must be flagged ground=true so the collision system resolves it on the Y-axis only. |
| 43 | Both | The judge can't parse BSP map files at runtime like the client does | Extract 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. |
| 44 | Both | Static world-geometry array too small for the map | Size 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 to | Pitfall | Fix |
|---|---|---|---|
| 45 | Standalone | Admin authorization mistaken for a p/-name check | There 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. |
| 46 | Standalone | New queue entry uses the paid matchId | Permanently 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. |
| 47 | Standalone | Payment-queue address comparison fails | Queue 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). |
| 48 | Standalone | Payout sent to the wrong address type | Payouts go to w0/w1 (wallet addresses), never a0/a1 (ephemeral session signing keys). |
| 49 | Standalone | Miscounting closing braces when hand-building the admin move JSON | The 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. |
| 50 | Standalone | Queue bootstrap silently does nothing | It 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. |
| 51 | Standalone | Contract / GSP / frontend disagree on which game they are wagering for | Three knobs must match exactly: the contract's gameNamespace() == the GSP's --game_id == the frontend's NEXT_PUBLIC_GAME_ID. |
| 52 | Standalone | Registering 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. |
| 53 | Standalone | Real-funds settlement test asserts on wallet balance deltas | Confounded when the operator address equals the funded/deployer address. Assert on the contract's MatchJoined event fields (queuePayee / winnerPayout; contracts/src/XayaPaymentQueueV5.sol) instead. |
| 64 | Standalone | The contract's replay marker is keyed without the payout amount | paymentMade[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. |
| 65 | Standalone | Join consent checked at the join and never again at the fill | A 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. |
| 66 | Standalone | Session signing address sent in any spelling but EIP-55 | The 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 to | Pitfall | Fix |
|---|---|---|---|
| 75 | Both | The relay is down when you connect and the driver never tries again | Under 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. |
| 76 | Arcade | waitForTransactionReceipt throws "receipt not found" for a transaction that mined | The 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. |
| 77 | Both | A disputer's client answers its own dispute | The 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. |
| 78 | Both | A second resolution is paid while the first is still pending | No 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). |
| 79 | Both | A resolution is sent twice after it mined | The 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). |
| 80 | Both | An un-awaited call kills the Node process | The 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). |
| 83 | Both | A large close is mined as a failure in the e2e sender | OnChainMoves.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. |
| 84 | Both | A signer address compared with a lowercase string never matches | The 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. |
| 86 | Arcade | A test or driver waits at an agreed free match and nothing ever starts | A 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. |
| 87 | Both | A 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 failure | getChannel 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). |
| 92 | Both | A mock GSP drops disputes and resolutions, so a finished game never closes | The 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 to | Pitfall | Fix |
|---|---|---|---|
| 54 | Both | IsValid() treated as a normal validation function | It 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. |
| 55 | Both | A dispute-timeout close never sets the board's finished flag | The 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. |
| 61 | Both | A TypeScript mirror of a consensus hash drifts from the blob, and the drift is a fork | Any 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. |
| 62 | Both | Turn-interleaving used for a game where the choice itself is the secret | P1 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. |
| 63 | Both | A commitment scheme where refusing to reveal is a safe move | A 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. |
| 56 | Both | Assuming one test command covers determinism | It 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. |
| 57 | Standalone | A low-height fork resolves every open dispute instantly | The 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.