← All decisions

Clamp dt inside pe.tick: a real, confirmed entity-entity tunneling risk after decision 0032 raised speed, made worse by zero dt clamping in the game loop

merge-gamephysics-correctnessrobustnessgame-loop

Context

Continuing autonomous development after the last round of feature/decision work (0032-0036): re-examined whether raising gravity and reducing drag (decision 0032, to make drops fall faster per direct user feedback) introduced any new correctness risk, since faster entities are exactly what makes discrete (non-swept) collision detection unsafe. glemy/game.gleam's loop computes dt directly from real requestAnimationFrame timestamps (`{timestamp -. previous} /. 1000.0`) with no clamp of any kind -- a real, not hypothetical, gap: a GC pause, a backgrounded browser tab regaining focus, or a slow device can and does produce a dt far larger than one real frame's worth. collision.overlap (glemy/pe/collision.gleam) only ever compares each frame's *end* positions -- it has no concept of the path an entity swept through between frames -- so a sufficiently large dt lets a fast-falling entity's integrated position land cleanly past another entity it should have collided with, with no frame in between where they ever actually overlapped. Confirmed this directly, not just reasoned about it: reproduced the exact failure with a synthetic large-dt pe.tick call before any fix (a falling entity ending up measurably below a target entity with a large gap, neither having registered a collision), then confirmed it's mathematically inevitable in general for this architecture (since gravity affects both entities identically in one integration step, their relative closing distance per frame reduces to `(v_a - v_b) * dt`, independent of gravity -- meaning any two entities with a large enough relative speed and a large enough dt can tunnel regardless of gravity's own value).

Options considered

Do nothing -- this is a pre-existing architectural characteristic (discrete collision detection), not something decision 0032 introduced — rejected
True that discrete collision detection always had this theoretical risk, but decision 0032 concretely raised real observed entity speeds (the whole point of that decision), which raises the probability of a real hitch actually triggering it -- from a purely theoretical edge case to a plausible one worth closing, especially given zero dt clamping already existed as a separate, compounding gap.
Adopt the classic fixed-timestep-accumulator pattern (an internal accumulator, stepping physics in fixed small increments, rendering an interpolated result) -- the textbook 'Fix Your Timestep!' (Gaffer On Games) solution, complete with its own ~0.25s accumulator clamp — rejected
Researched directly rather than assumed: this project's actual architecture is a single variable-dt integration per real frame (pe.tick called once per requestAnimationFrame with whatever dt it's given), not a fixed-step accumulator loop -- the commonly-cited ~250ms accumulator clamp exists to bound how much *catch-up debt* piles up before an accumulator drains it in many small fixed sub-steps; it doesn't directly transfer to a single-step architecture, where a 250ms dt handed straight to one integration call would itself be the tunneling hazard, not a bound against it. Adopting the full accumulator pattern would be a real, invasive restructuring of pe.tick/glemy/game's loop for a problem a much smaller, additive fix already solves.
Clamp dt to a small maximum (pe.max_dt) inside pe.tick itself, before it's used for anything -- update (the pure physics primitive) stays unclamped, so its own existing tests keep exercising true, unclamped integration — chosen
Directly bounds the worst-case single-tick displacement regardless of how large a real dt gets reported, with a minimal, additive change (one const, one float.min call) rather than restructuring the game loop's architecture. Placed inside tick (not just in glemy/game's loop) so the safety guarantee is a genuine invariant of the physics engine's real entry point, protecting any future caller, not a Shell-side convention a future change could forget to apply. 1/30s chosen (not the accumulator pattern's ~0.25s, which would still tunnel here) so even two consecutive stalled real frames still integrate at a rate this project's own collision/rest-detection tuning (genuine_impact_velocity, sleep_disturbance_velocity) was actually verified against.

Decision

Added pub const max_dt = 1.0 /. 30.0 (~0.0333s) to pe.gleam, with a doc comment explaining why this project's single-step architecture needs a much smaller bound than the classic accumulator-pattern clamp. pe.tick now does `let dt = float.min(dt, max_dt)` as its very first line, before any other use of dt -- update, tick's own pure physics primitive, is deliberately left unclamped so its existing test suite keeps testing true integration math. Nine existing pe_test.gleam tests and two game_test.gleam tests that previously passed a dt larger than max_dt (expecting it to be used exactly as given) were updated: most simply use pe.max_dt as their own dt and recompute expected values via the same formula entity.integrate/entity.settle use internally (not hand-typed products, so they can't silently drift out of sync with max_dt again); one (the cooldown-elapses-fully test) was changed to accumulate real elapsed time across 16 max_dt-sized ticks instead of one artificially large dt call, which also more faithfully matches how the real per-frame loop actually behaves.

Verification

162 Erlang / 185 JavaScript gleam test cases passing on both targets (one new permanent regression test, tick_clamps_an_unreasonably_large_dt_to_avoid_tunneling_test, replacing the ad hoc diagnostic scaffolding used to first confirm the bug and then verify the fix -- consistent with this project's standing practice of not shipping throwaway debug code). The regression test reproduces the exact scenario that demonstrated real tunneling before the fix (a target entity in open space, not resting on the floor, so the floor's own position clamp can't mask the result either way) and asserts the two entities stay correctly ordered (falling strictly above target) after a deliberately extreme 1-second synthetic hitch, which an unclamped tick would have closed the entire 45-unit gap more than three times over in one step. Re-ran tools/browser_check.ts against a real headless-Chromium session after the change: still passes cleanly with zero page errors, confirming normal 60fps gameplay (where real dt is always comfortably under max_dt) is completely unaffected by the clamp.

Consequences

A real stall (GC pause, tab backgrounding, slow device) now produces bounded slow-motion for that moment rather than a silent tunnel-through or an unphysical single-frame velocity spike -- the standard, accepted tradeoff for this failure mode. pe.max_dt is now the canonical place any future caller of pe.tick should look to understand the engine's own safety bound, rather than assuming dt is used verbatim. update remains available, deliberately unclamped, for any test or future use genuinely needing to exercise raw, unbounded integration math.

References