Fix the seventh-round "never-ending dice stacking" bug (locked dice past 10 + dropped-broadcast desync) - #72
Merged
Conversation
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQQP3SV3GX4V7GvGB6hrdY
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQQP3SV3GX4V7GvGB6hrdY
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQQP3SV3GX4V7GvGB6hrdY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Chases down the reported "seventh round" bug where a player's board kept stacking locked dice past 10 and the round never resolved for them. Investigation turned up two distinct client-side defects (the server is correct throughout —
lockednever exceeds 10 and rounds always advance). Both are fixed here, plus a belt-and-suspenders resync guard.1. Locked dice stacking past 10 (
animations.js)The locked-dice zone was rebuilt by blindly appending a delta (
prevMatchedCount → newMatched.length) on every roll. Under the reveal/rebuild races a fast-tapped game produces, the live zone's child count drifts from theprevMatchedCountbasis, so the delta over-appends and the pile grows past 10. The pop now reconciles the zone to exactly the snapshot's matched dice (trim any excess, animate only the genuinely-missing dice), and a pop whose round has already advanced bails instead of dropping stale dice into the next round.2. Dropped-broadcast desync — the real "frozen board" (
state.js,game-render.js,animations.js)On a flaky connection a client can miss the
round_wonand the round-advance broadcast. 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 animated that response in place onto the stale board — keeping the previous round's locked dice and stacking the new target on top (e.g. matched zone[1,1,1,1,1,2,2]), with no win overlay. This matches a real report: old 1s stayed locked, new 2s piled on, and the player had to wait for the next round to resync.Fix: track the round each board was built for (
state.boardRound), and hard-rebuild inupdateDiceInPlacewhen a reveal is for a different round. The client now catches up on its next roll, not just on the next broadcast it happens to receive.3. Catch-up guard (
net.js)Belt-and-suspenders for a client that's wedged or simply stops rolling: a
stateframe for a round ahead of the one on screen is now applied immediately (reset the roll machine + route) instead of being stashed behind an in-flight roll. There's no longer any path where a client stays parked on an old round while the server moves on — it resyncs on the next frame it hears, whichever comes first.How it was verified (two Playwright clients, mobile viewport)
round_won+ advance frames on one client, then rolled. 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). Same-round in-place reveals unaffected.awaitingAckstuck, pending state stashed) on the old round and had it not roll — it resynced to the new round the instant the opponent's next broadcast arrived.tsc -p jsconfig.jsonpasses on the merged tree.Commits
(Includes a merge of
origin/main.)🤖 Generated with Claude Code
Generated by Claude Code