Melon Drop is a fruit merge game: drop fruit into a container, matching pairs merge into the next size up, the pile grows. It is the first game in our catalogue with resting contacts — bodies that touch, stay touching, and hold each other up.
Everything before it was impulse-and-ballistic. A pool ball hits another and they separate. A golf ball rolls and stops. Nothing had to rest on anything else, indefinitely, without jittering or sinking or exploding.
That turns out to be the hard part of a physics engine, and the bug we hit is worth writing down because it looks like nothing.
The symptom
A fruit sat perfectly still, at the bottom of the pile, carrying 31.36 units per second of downward velocity. Not moving. Displacement of zero, frame after frame. And carrying enough downward velocity to have fallen through the floor several times over.
It was a stable limit cycle. We watched it hold, unchanged, over fifteen simulated seconds. The stack looked almost right, and it had a quiet vibration to it that never resolved.
The cause
A physics step has three phases: solve velocities, integrate positions, then correct any positional overlap that integration produced.
Ours ran the position-correction pass before positions were integrated.
Follow what that does. The correction pass finds two touching bodies and separates them slightly, which is its job. But it is running early, so it separates them before they have moved. Next frame, the contact-detection step looks for touching bodies — and finds none, because the previous frame pushed them apart.
No contact means no contact constraint. No contact constraint means nothing cancels gravity. So gravity keeps accumulating into the velocity, every frame, forever, while the position solver keeps shoving the body back to exactly where it was.
Still. And falling. Both at once, permanently.
Six changes, and the honest thing to say about them
The fix was not one line. Six changes went in together: the ordering, plus sleeping, plus velocity thresholds, plus how the correction distributes between two bodies, plus iteration counts, plus the restitution cutoff at low approach speeds.
Here is the part worth being careful about. Reverting any single one of those six leaves the test suite green. Reverting the architecture wholesale turns seven assertions red.
The tempting conclusion is that the suite is weak. It is not. The six fixes are genuinely redundant with each other — each one independently prevents the limit cycle by a different route — so no single one of them is load-bearing. That is a real property of the system.
But you cannot tell that case apart from a suite that checks nothing without also running the wholesale revert. If the wholesale revert had stayed green too, we would have had a serious problem and would not have known it. So: when individual reverts stay green, do not conclude anything until you have tried taking the whole thing out.
And record it honestly rather than inventing a tidier story about one root cause. The tidy story would have been wrong.
The sabotage that stayed green
While we were hardening this game, one more thing surfaced that belongs here.
Melon Drop's comparability rests on a seeded fruit queue — your seed and my seed
deal identical fruit, so a shared score means something. The determinism check
called queueFrom(), a pure reimplementation of the queue exposed on the test
seam because it was convenient.
Swapping the game's queue to Math.random left all four assertions green.
The helper was still perfectly deterministic; the helper was never the thing
under test.
Prove the live path. Drop fruit through the real input function and read back what was actually dealt. Then, separately, assert the convenience helper agrees with it. This is the same failure that made our golf game unloseable, arriving in a completely different genre about six weeks later.
Play it: Melon Drop · how to play
Related reading: three ways a suite passes with the rule deleted · our golf game was unloseable