A fruit-drop game has a deceptively small rule: when two matching fruits touch, replace them with the next fruit. The awkward part is that a physics engine reports contacts, while the game needs to perform a single state transition.
Consider three equal fruits, A, B, and C. One collision event can contain both (A, B) and (B, C). If each pair independently schedules a replacement, B can be consumed twice. Checking whether the bodies exist only when the callback begins does not solve that problem when removal is deferred.
This note comes from the merge handler in a project I work on, SuikaGo (スイカゲーム). The snippets below are abbreviated illustrations of the implementation, not a complete physics integration.
Separate the rule from the engine
The merge rule is a pure function. It receives two tier numbers and returns one of three outcomes:
type Outcome =
| { kind: "evolve"; resultTier: number; score: number }
| { kind: "clear"; score: number }
| null;
Different tiers return null. Matching ordinary tiers advance by exactly one. Two final-tier watermelons clear instead of requesting a nonexistent larger fruit. The score comes from the tier being consumed.
That last distinction matters: “there is no next tier” does not necessarily mean “nothing happens.” A nullable next-tier index cannot fully represent both evolution and clearing. An explicit outcome keeps the collision handler from having to rediscover that rule.
Reserve both bodies before scheduling work
The handler keeps a set of body IDs currently involved in a merge. The important ordering is:
if (reserved.has(a.id) || reserved.has(b.id)) return;
const outcome = getMergeOutcome(tier(a), tier(b));
if (!outcome) return;
reserved.add(a.id);
reserved.add(b.id);
scheduleMerge(a, b, outcome);
The reservations happen synchronously, before the deferred callback. If (A, B) is accepted first, (B, C) sees B's reservation and cannot also claim it. A later collision involving a newly created fruit can produce a legitimate chain reaction.
This enforces at most one pending merge per body. It does not make the whole simulation deterministic: when several valid contacts compete, the chosen pair can still depend on the engine's pair order. Deterministic replay would require additional work, including a stable contact-selection policy and control of simulation timing.
Revalidate when the deferred callback runs
The implementation defers world mutation with requestAnimationFrame. By that point, either source body may have disappeared—for example, because the board was reset.
Before removing anything, it collects the IDs still in the physics world and checks that both sources remain present. If either is absent, it releases the reservations and exits. Otherwise it removes the two sources and, for an evolve outcome, creates one replacement. A clear outcome creates none.
The replacement starts at the midpoint of the original positions. Its initial velocity averages the source velocities with a small upward adjustment; angular velocity is averaged as well. Those are game-feel choices, not a claim of physically accurate conservation. The merge rule remains independent of them.
There is also a lifecycle detail worth keeping explicit: requestAnimationFrame is a browser scheduling mechanism, not a physics transaction API. A game with background simulation, replay, or several physics substeps per frame may need a dedicated mutation queue flushed at a controlled engine boundary instead.
Test rules separately from scheduling
I reran the project's seven merge-logic tests for this note. They cover same-tier evolution, mismatched tiers, the final-tier boundary, terminal clearing, and score lookup. All seven passed.
Those tests validate the pure rules. They do not prove that concurrent collision callbacks are correct. For the integration layer, the useful cases are different:
- A callback containing
(A, B)and(B, C)must not consume B twice. - A repeated pair while its first operation is pending must not queue another merge.
- Resetting the board before the callback runs must not recreate the old fruits.
- Clearing the final tier must remove both sources without adding a replacement.
Keeping these two test layers separate makes failures easier to diagnose. A wrong tier or score belongs to the rule function; a duplicate body or stale callback belongs to the scheduling and world-mutation layer.
The transferable idea is to model resource ownership before deferring a state change. Physics contacts, drag-and-drop events, and animation callbacks can all describe overlapping work. Reserving the inputs first prevents several callbacks from believing they own the same object.
Disclosure: this article discusses a project I work on. AI assisted with drafting; implementation details were checked against the project code and the seven rule tests were run for this article.
Top comments (0)