Skip to main content
Zhimalab
中文

The Bubble Game Freezes the More You Click? An Infinite Loop Hiding in a Collision Loop

2026-08-04 · 6 min

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:

  1. The inner loop keeps matching against the already-destroyed a;
  2. The new bubble sits at the midpoint of a and b — its distance from each is exactly d/2, and since d < r_a + r_b, the new bubble inevitably overlaps a;
  3. In j < list.length, length grows every iteration, so the condition is always true → infinite loop;
  4. 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.dead before 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 length and 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.