From bb34682e5a62f02ee180459a906c7b88579882e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 13:04:58 +0000 Subject: [PATCH 1/3] Fix locked dice stacking past 10 in the reveal animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locked-dice zone was rebuilt by blindly appending a delta each roll (prevMatchedCount → newMatched.length). Under the reveal/rebuild races a fast-tapping game produces, the live zone's child count drifts from the prevMatchedCount basis, so the delta over-appends and the zone stacks past 10 dice — the "matched 9 at once, then kept stacking beyond 10 locked, could never win" report. Server state is unaffected: locked never exceeds 10 and rounds advance normally; the bug is purely the client display. The pop now reconciles the locked zone to exactly the snapshot's matched dice: re-query the live zone, trim any excess, then pop in only the genuinely-missing dice so the animation still plays. A stale pop whose round has already advanced bails instead of dropping old matched dice into the next round's fresh zone. Reproduced (two auto-rolling clients racing): pre-fix the zone reached 14 dice with 85 over-10 violations by ~round 19; post-fix both clients stay capped at 9 through round 24 with zero violations. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UQQP3SV3GX4V7GvGB6hrdY --- static/js/animations.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/static/js/animations.js b/static/js/animations.js index a4165db..4377415 100644 --- a/static/js/animations.js +++ b/static/js/animations.js @@ -161,11 +161,28 @@ export function updateDiceInPlace(snap, onComplete, winForMe = false) { return; } - const matchedZone = document.querySelector('.zone-matched'); if (newlyMatchedCount > 0) { const popT = setTimeout(() => { + // A pop the round has moved past must not drop its now-stale matched + // dice into the next round's fresh zone, so bail once a later snapshot + // (a round advance) has replaced ours. + if (state.currentState && state.currentState.round_num !== snap.round_num) { + if (onComplete) onComplete(); + return; + } + // Reconcile the locked zone to exactly this snapshot's matched dice + // instead of blindly appending prevMatchedCount→length. Under a + // reveal/rebuild race the live zone can already hold a different count + // than prevMatchedCount assumed, and a blind append then stacks it past + // 10 (the "locked dice keep stacking beyond 10" bug). Re-query the live + // zone, trim any excess, then pop in only the genuinely-missing dice so + // the animation still plays. + const matchedZone = document.querySelector('.zone-matched'); if (matchedZone) { - for (let i = state.prevMatchedCount; i < newMatched.length; i++) { + while (matchedZone.children.length > newMatched.length) { + matchedZone.lastElementChild?.remove(); + } + for (let i = matchedZone.children.length; i < newMatched.length; i++) { const scene = makeDie(newMatched[i], effectiveTarget); scene.classList.add('popping'); matchedZone.appendChild(scene); From 2ffa4915269c638a8e5c1f4620f8957b0035dc63 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:14:27 +0000 Subject: [PATCH 2/3] Resync a stale board when a round-advance broadcast is lost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a flaky connection a client can miss the round_won + round-advance broadcasts entirely. The server moves on; the client stays on the old round. Its next roll is processed server-side in the new round, but the reveal path animated that response in place onto the old board — keeping the previous round's locked dice and stacking the new target on top. The result is a board that never clears and never wins (the reporter's wife: old 1s stayed locked, new 2s piled on, no win overlay, had to wait for the next round's broadcast to resync). Track the round each my-area board is built for (state.boardRound, set in renderMyArea) and, in updateDiceInPlace, hard-rebuild instead of animating in place when the incoming snapshot is for a different round. The client then catches up on its very next roll, not just on the next broadcast it happens to receive. Reproduced deterministically by dropping the round_won + advance frames on one client, then rolling: pre-fix the matched zone showed [1,1,1,1,1,2,2] (stale ones + stacked twos); post-fix it rebuilds to a clean round-2 board ([2], then 2/3/4 twos as normal), and same-round in-place reveals are unaffected. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UQQP3SV3GX4V7GvGB6hrdY --- static/js/animations.js | 9 ++++++++- static/js/game-render.js | 3 +++ static/js/state.js | 4 ++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/static/js/animations.js b/static/js/animations.js index 4377415..fa6e3ae 100644 --- a/static/js/animations.js +++ b/static/js/animations.js @@ -77,7 +77,14 @@ export function updateDiceInPlace(snap, onComplete, winForMe = false) { const player = state.myId ? snap.players[state.myId] : undefined; const wrappers = /** @type {HTMLElement[]} */ ([...document.querySelectorAll('.zone-unmatched .die-wrapper')]); - if (!player || wrappers.length === 0) { + // If the board on screen was built for a different round, we fell behind the + // server — a round-advance broadcast was lost (flaky link) — and are only now + // catching up through this roll response. Animating in place would paint the + // new round's dice onto the stale board: old locks kept, new target stacking + // on top (the dropped-broadcast frankenboard). Hard-rebuild to the round the + // snapshot actually describes. + const staleBoard = state.boardRound != null && snap.round_num !== state.boardRound; + if (!player || wrappers.length === 0 || staleBoard) { renderMyArea(snap); renderPlayersBar(snap); if (onComplete) onComplete(); diff --git a/static/js/game-render.js b/static/js/game-render.js index 534263b..29dc504 100644 --- a/static/js/game-render.js +++ b/static/js/game-render.js @@ -97,6 +97,9 @@ export function renderPlayersBar(snap) { export function renderMyArea(snap) { const player = state.myId ? snap.players[state.myId] : undefined; if (!player) return; + // Record the round this board belongs to so a later reveal can tell whether + // the board is still current (see updateDiceInPlace's stale-board guard). + state.boardRound = snap.round_num; const effectiveTarget = player.has_rolled ? snap.target : -1; const matched = player.dice.filter((d) => d === effectiveTarget); diff --git a/static/js/state.js b/static/js/state.js index b4bb77d..ba0cd36 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -41,6 +41,10 @@ export const state = { // ── Game board / roll choreography (driven by the game view) ── /** @type {string | null} Fingerprint to skip needless my-area re-renders. */ lastMyDiceKey: null, + /** @type {number | null} Round the my-area board was last (re)built for. Lets + * the reveal detect a stale board — a round-advance broadcast we never got — + * and hard-rebuild instead of animating new dice onto the old round. */ + boardRound: null, /** True while the shake animation is running. */ rolling: false, /** True while waiting on the server's roll response. */ From e5e80c2e59c7869739ab8fe0bfb372a6df9bd8b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 15:47:07 +0000 Subject: [PATCH 3/3] Force-apply a round-ahead state frame instead of stashing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belt-and-suspenders for the dropped-broadcast case: if a `state` frame arrives for a round ahead of the one we're displaying, the client missed the round-advance broadcast (flaky link). Previously such a frame was stashed behind an in-flight roll (pendingRollState / postRevealState) and only applied when the reveal completed — so a client that was wedged (awaitingAck stuck) or simply stopped rolling could stay parked on the old round while the server and everyone else moved on. A newer round supersedes any in-flight roll, so apply it immediately (reset the roll machine + showFor) rather than stashing. The client now resyncs on ANY subsequent frame that's ahead of its round, not just its own next roll. Verified: a client wedged on the old round (awaitingAck forced true, pending state stashed) that never rolls resyncs to the new round the instant the opponent's next roll broadcast arrives — matched zone clears, roll machine resets. Happy path unaffected: a normal win still shows the loser overlay and advances one round cleanly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01UQQP3SV3GX4V7GvGB6hrdY --- static/js/net.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/static/js/net.js b/static/js/net.js index ec438db..df9a87d 100644 --- a/static/js/net.js +++ b/static/js/net.js @@ -276,6 +276,19 @@ function handleMessage(msg) { return; case 'state': if (msg.qr) state.qr = msg.qr; // re-sent on a lobby reconnect + // Authoritative catch-up: a frame for a round AHEAD of the one we're + // showing means we missed the round-advance broadcast (dropped on a flaky + // link). A newer round supersedes any in-flight roll, so apply it now + // rather than stashing it behind a reveal that might never run — otherwise + // a client that also stops rolling stays parked on the old round while the + // server and everyone else move on. + if (msg.started && state.currentState + && typeof msg.round_num === 'number' + && msg.round_num > (state.currentState.round_num ?? 0)) { + resetRollState(); + showFor(msg); + return; + } // My own roll response (private, pre-broadcast): hold it for tryReveal // so the shake/reveal animation drives the change instead of a hard // re-render. A newer broadcast landing mid-reveal is stashed separately