← All decisions

Fixed a genuine resonance bug in decision 0027's resting-contact threshold: replaced the hard velocity cutoff with a smooth restitution ramp plus a Box2D-style time-based sleep backstop

merge-gamephysics-correctnesscollisionboundssleeprestitution

Context

User report, after decision 0027 shipped: 'the bounce mechanism is completely gone' and physics felt sluggish, with an explicit instruction to research real solutions online rather than guess at a fix on the fly. Investigation started by reading Box2D's actual source (b2ContactSolver.cpp): restitution is applied only when the relative velocity along the contact normal exceeds b2_velocityThreshold (1.0), otherwise treated as inelastic -- confirming decision 0027's hard-threshold approach (resting_contact_velocity = 3.0) was directionally the right idea. First hypothesis: the threshold was simply mistuned, too high, killing genuine bounces. Derived a new value (10.0) from Box2D's own gravity-scaled ratio. gleam test passed (144/178) with this change. Only real-browser empirical testing (a headless-Chromium probe dropping two different-tier entities to stack, repeated 3 times, reading actual GPU-uploaded positions over 10+ seconds) revealed this was wrong: in 2 of 3 runs, the stack fell into an exact, never-decaying resonance, with the top entity's y oscillating precisely between 3.8095 and 13.8095 forever. Reverting the threshold back to 3.0 (decision 0027's original value) did NOT fix it either -- the same resonance recurred in 2 of 3 runs at that value too, proving the bug was pre-existing in 0027, not introduced by the 10.0 experiment. A deterministic Gleam-level diagnostic test (two entities of different tiers, one resting, one dropped on top, run for hundreds of fixed-dt ticks with per-frame state traced via `echo`) isolated the actual mechanism: the coupled two-body system under continuous gravity settles into a stable limit cycle whose peak contact velocity (4-7 units/s in this scenario) sits reliably ABOVE any hard threshold that could reasonably be chosen -- this is not a tuning problem, it is a structural flaw in any single-frame, binary velocity check: a bounce landing at or above the cutoff always keeps full elastic reflection, so the dynamics can settle into -- and never escape -- a fixed point balanced exactly on top of whatever line is drawn, however far 'below' it that line nominally sits.

Options considered

Keep tuning the single hard velocity threshold (resting_contact_velocity) to a different value — rejected
Empirically disproven twice in this same investigation -- both 3.0 (the original decision 0027 value) and 10.0 (a Box2D-gravity-ratio-derived value) produced the identical class of exact, never-decaying resonance in real multi-second browser traces. A hard threshold is a discontinuity the coupled gravity system can always find a fixed point on, regardless of where the line is drawn; no single number fixes a structural flaw.
Replace the hard threshold with a smooth restitution ramp alone (effective_restitution = restitution * min(1.0, velocity_along_normal / genuine_impact_velocity), continuous, no discontinuity to balance on) — rejected
Implemented and tested via the same deterministic diagnostic test -- eliminated the oscillating resonance, but replaced it with a different, equally wrong failure mode: the system converged to a completely frozen, bit-identical-across-dozens-of-frames state with a persistent NONZERO velocity (~-1.35 units/s), because gravity's constant per-frame energy injection had found a new equilibrium where the ramp's proportionally-reduced damping exactly cancels it, frame after frame. A purely reactive, single-frame/single-contact mechanism -- continuous or not -- can always be balanced by a system that injects energy continuously every frame; only a mechanism with memory across frames (this option had none) can break that. Not abandoned outright though -- see the chosen option below, which keeps this ramp and adds what it was missing.
Smooth restitution ramp (replaces the hard threshold, fixing the true energy-injection-per-bounce problem) plus an independent, time-based sleep backstop matching Box2D's own two-stage design (b2_timeToSleep / linear+angular sleep tolerances): track how long each entity has stayed continuously below a 'quiet' velocity, and force it to a hard stop once that duration exceeds a fixed threshold, regardless of what any single contact's velocity check says that frame — chosen
This is what Box2D itself actually does -- restitution scaling and sleeping are two separate, independently-necessary mechanisms there, not one doing both jobs. The sleep timer has memory across frames (resting_time, a new Entity field), which is exactly the property the previous two attempts lacked: no single-frame energy balance can prevent a duration-based counter from eventually crossing its threshold, since the counter only resets when the entity is NOT quiet, and being quiet is (by the ramp fix) now actually converging, not just oscillating below a line.

Decision

glemy/pe/bounds.gleam and glemy/pe/collision.gleam both replace their old resting_contact_velocity hard threshold with a shared genuine_impact_velocity constant (15.0) used as a smooth ramp reference: effective_restitution = restitution * min(1.0, velocity_along_normal / genuine_impact_velocity), so any impact strictly below 15.0 returns proportionally (quadratically, since both the scale factor and the impulse itself shrink with velocity) less energy than it received, with no discontinuity anywhere for a resonance to balance on. Separately, glemy/pe/entity.gleam gains a resting_time: Float field and two new constants, sleep_disturbance_velocity (8.0) and sleep_duration (0.5 seconds, matching Box2D's own b2_timeToSleep exactly) -- settle now accumulates resting_time whenever damped speed stays below sleep_disturbance_velocity, resets it to 0 the instant speed exceeds that, and force-zeroes velocity outright once resting_time reaches sleep_duration, independent of the existing (tiny, 0.05) rest_velocity_threshold cleanup snap. resting_time is deliberately excluded from entity.loosely_equals (internal bookkeeping, not physically meaningful for equality) and resets to 0.0 on tier.merge (a freshly merged entity is physically a new event, not a continuation of either parent's quiet streak). Every Entity construction site across the codebase (pe.gleam, pe/tier.gleam, the cs/entity_batch_ffi.mjs GPU-batch roundtrip, and the full test suite) was migrated to carry the new field; pe.tick's settle call now threads dt through so the timer can accumulate real elapsed time rather than a fixed per-call amount.

Verification

Re-ran the same deterministic two-different-tier-stack diagnostic test used to catch both prior failed attempts: velocity converges to and stays at exactly 0.0 (not a nonzero frozen value, not an oscillation) at a stable resting position, confirmed on both the Erlang and JavaScript targets (147/181 gleam test passing, both green, zero failures). Re-ran the real headless-Chromium browser probe three times (matching the exact rigor applied to catching both prior failures): all three runs show the two entities settling to stable, non-oscillating resting y-positions over a 10-second real-time window -- one run even completed a real merge (score 1) with the resulting single entity motionless at y=6.000 for the full observed tail. test/glemy/pe/bounds_test.gleam and test/glemy/pe/collision_test.gleam were both comprehensively rewritten (not just mechanically patched) to test the new ramp's actual shape: full-strength reflection at/above genuine_impact_velocity, proportional softening below it (expected values computed via the same formula the source uses, not hand-typed decimals), and confirmation the ramp's cap never amplifies a fast impact above its own incoming magnitude.

Consequences

Resting stacks of entities -- including merged, larger-tier ones -- now genuinely and permanently come to rest under continuous gravity, without either the perpetual-bounce symptom the user originally reported or the two intermediate failure modes (resonance, frozen nonzero velocity) found and rejected during this same investigation. entity.gleam, bounds.gleam, and collision.gleam's doc comments were written to record this full chain of reasoning (including the two real, empirically-caught wrong turns) in place, so a future reader doesn't have to rediscover from scratch why a single hard threshold or a memoryless ramp alone are both insufficient. The temporary diagnostic test (echo-based frame tracing) used throughout this investigation was replaced with a permanent regression test, stacked_entities_of_different_tiers_eventually_come_to_rest_test in test/glemy/pe_test.gleam, asserting the actual invariant (velocity reaches and stays at exactly zero, position stable within a loose tolerance) rather than leaving throwaway debug output in the shipped suite.

References