Skip to content

Fix the seventh-round "never-ending dice stacking" bug (locked dice past 10 + dropped-broadcast desync) - #72

Merged
radiantnode merged 5 commits into
mainfrom
claude/seventh-round-dice-bug-9ckkme
Jul 18, 2026
Merged

Fix the seventh-round "never-ending dice stacking" bug (locked dice past 10 + dropped-broadcast desync)#72
radiantnode merged 5 commits into
mainfrom
claude/seventh-round-dice-bug-9ckkme

Conversation

@radiantnode

Copy link
Copy Markdown
Owner

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 — locked never 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 the prevMatchedCount basis, 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_won and 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 in updateDiceInPlace when 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 state frame 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)

  • Stacking: reproduced the matched zone hitting 14 dice; post-fix it stays capped at 9 across 20+ rounds.
  • Dropped-broadcast desync: deterministically dropped the 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.
  • Catch-up guard: forced a client into the wedged state (awaitingAck stuck, 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.
  • Regression: a normal win still shows the loser overlay and advances one round cleanly.
  • tsc -p jsconfig.json passes on the merged tree.

Commits

  • Fix locked dice stacking past 10 in the reveal animation
  • Resync a stale board when a round-advance broadcast is lost
  • Force-apply a round-ahead state frame instead of stashing it

(Includes a merge of origin/main.)

🤖 Generated with Claude Code


Generated by Claude Code

claude and others added 5 commits July 9, 2026 13:04
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
@radiantnode
radiantnode merged commit ab7f19f into main Jul 18, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants