Symptoms
After Bubble Bliss went live, users reported “the more I click, the page freezes”. Static code review locally found nothing, so I reproduced it directly against the live page.
Investigation
1. Playwright reproduction on the live site
After 200 consecutive clicks in the game area, the page’s main thread completely froze — even location.href couldn’t be read back; when pressure continued, the browser’s renderer process crashed outright.
2. Decompiling the production bundle
I grabbed the live scene chunk and decompiled it, confirming the production code was exactly the old approach that “directly calls mergeBubbles inside the inner collision loop” — identical to local code.
3. Algorithm simulation to find the root cause
I ported the handleCollisions algorithm verbatim into a Node simulation — all 20 groups of random bubbles hit an infinite loop.
Root cause: merging while iterating
for (let i = 0; i < list.length; i++) {
const a = list[i];
for (let j = i + 1; j < list.length; j++) {
if (canMerge) this.mergeBubbles(a, b); // ← the problem is here
}
}
mergeBubbles(a, b) does two things: destroys a and b, and pushes a new bubble at the end of the array. Therefore:
- The inner loop keeps matching against the already-destroyed
a; - The new bubble sits at the midpoint of
aandb— its distance from each is exactlyd/2, and sinced < r_a + r_b, the new bubble inevitably overlapsa; - In
j < list.length,lengthgrows every iteration, so the condition is always true → infinite loop; - Every iteration inflates the array by one element, creating tens of thousands of Phaser Sprites/Tweens per frame → memory blowup and renderer crash.
One geometric fact (the midpoint inevitably overlaps) + one iteration habit (modifying the collection while looping) combine into a guaranteed freeze.
Fix: separate scanning from merging
// scan phase: only record pairs, don't modify the array
for (let i = 0; i < list.length; i++) {
if (canMerge) merges.push([a, b]);
else this.separate(a, b);
}
// merge phase: process pairs one by one, skip destroyed, cap 8 per frame
for (const [a, b] of merges) {
if (a.dead || b.dead) continue;
if (mergedCount >= MAX_MERGES_PER_FRAME) break;
this.mergeBubbles(a, b);
}
- Scan first, merge later — the array isn’t modified during iteration;
- Check
a.dead || b.deadbefore merging to prevent duplicate merges; - Cap merges per frame to avoid burst cost from cascading merges.
After the fix, the same simulation ran 120 frames × 20 groups — all terminated normally, with a stable array length. The merge gameplay is unchanged: same-color bubbles still merge on touch, and after 3 merges the 10-second “bubble fairy tale” still triggers.
Lessons
- Don’t modify a collection while iterating over it. Pushing, deleting or destroying objects makes
lengthand indices lose their meaning. - Infinite loops don’t always hide in a conspicuous
while (true)— they can hide in a “seemingly bounded double loop” whenever the loop condition references a quantity modified by the loop body. - For “freezes the more I click” issues, algorithm-level simulation beats staring at code: extract the core logic and run it — you get the answer in tens of milliseconds.