# 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 to | Pitfall | Fix | |---|---|---|---| | 1 | Arcade | Bundle built without `NEXT_PUBLIC_GAME_ID` | Uploads, passes pre-flight, gets accepted — 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. | | 2 | Arcade | Removing the template's `rules/heritage/` per its README leaves a broken build | Besides the directory and the two Makefile targets you must also remove the protobuf gen rule + `PB_CC`, the `\| $(PB_CC)` order-only prereqs on the surviving object rules, `HERITAGE_OBJS`, the two oracle test `.cpp` files, and fix `run-tests.sh` + the CI labels (`blob/tests/Makefile:75-110`). Delete heritage *together with* the old rules — it is a test oracle valid only against the rules it was ported from. | | 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:136-137`). 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. | | 4 | Arcade | Stale `blob/tests/.build/cmake` after editing `rules/CMakeLists.txt` | The generated cmake dir has no dependency on your CMakeLists — the `$(CMAKEDIR)/Makefile` rule only fires when the file is absent (`blob/tests/Makefile:34-35`), so a target rename fails with "No rule to make target". `rm -rf blob/tests/.build` after any CMakeLists change. | | 5 | Arcade | cfg suffix omitted at upload | 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 a shipped example of each of the three routes. | | 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. Xayaman, Xayatrails and Dungeon Channel export weights; Xayaships and Vector Sumo do not. Decide it deliberately; ARCADE.md §6 owns the wire format, the fallback and three worked vectors including a *deliberate* winner-take-all. | | 60 | Arcade | `useGspPolling()` stopped returning `height` / `blockHash` | **Breaking in SDK 0.15.1.** They moved to **`useGspBlock()`**, to be read **in the component that displays them** — that locality is the point, and hoisting it to the top of a screen re-creates the idle-tab redraw the change existed to fix. `WagerGspInputs` likewise lost its `height` field in 0.15.2. ARCADE.md §3 step 3 owns both, and the rest of the idle-tab work you should not re-implement. | | 67 | Arcade | A match fills on chain and the board never starts | The relay closes the socket a re-join supersedes, and until **SDK 0.16.5** the client read that close's `1000` code as its own hang-up: no reconnect, no fallback poll, `status` still reading connected, and the tab never learned its own match had filled. Two windows of one game, or an old half-open socket after a network drop, are enough to reach it. Vendor 0.16.5 or newer and rebuild; the relay wants the matching change too (a superseded socket closes with 4001). ARCADE.md §3 step 3 owns the mechanism and the multi-tab behaviour that came with it. | | 68 | Arcade | Assuming the browser keeps your bundle out of the shell's realm | It depends on the plane, and on the arcade today it does **not**: a plane whose every bundle is first-party may serve the games from the shell's own origin (`/g//` on the shell's hostname), which is exactly what the arcade does. One `localStorage`, one DOM, one realm. Never write outside your own `storagePrefix`, never take `'arcade'`, and never treat the origin as a safety net you are allowed to lean on. ARCADE.md §5 owns the two shapes and how to tell which one you are on. | | 69 | Arcade | After bumping to SDK 0.18.x the repo will not compile, in the adapter test | Two renames-in-effect land in one bump. **0.18.0** made `GameAdapter.controlsHint` a `string \| { keyboard, touch }` union, so anything READING it as a string (`.length`, `.toContain`, passing it where a `string` is required) fails with TS2339 — every game repo's own adapter test does this. Narrow with `typeof hint === 'string'`, or resolve it: `resolveControlsHint(hint, coarsePointer)` is exported. **0.17.0** renamed `useChannelManager`'s `moneyErrors` / `dismissMoneyError` to `sendErrors` / `dismissSendError`, which bites only an app that builds its own board screen on the hook. ARCADE.md §3 step 3 owns both. | | 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 (SDK 0.17.0) 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. | ## 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. | ## 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`: monotonically non-decreasing across every `applyMove` AND across a dispute-timeout reinit, which the host requires not to regress it (`docs/ARCADE-ABI.md` §2). `ParsedBoardState` declared a `moveCount()` alongside it until SDK 0.6.0 and every game implemented one as a bare passthrough to the other — a shape whose only possible future is two counters disagreeing, silently, in the comparison that decides who wins a dispute. If your rules' natural turn number is non-monotonic 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. | | 19 | Both | Auto-move feedback loop makes both players' AI run ~5× too fast | If `maybeAutoMove()` — the ONLY method on `OpenChannel` since SDK 0.6.0 (`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`. | ## 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 `