← All decisions

Add physics/bounds.clamp_x and physics.settle_all, reject a bounds.y_from_top helper, and fix a real float.clamp/bounce_axis divergence on degenerate bounds

architecturecore-apiboundsrefactor

Context

Direct follow-up to decision 0049, same standing instruction: keep growing the Core API with small, research-backed, independently-testable utilities extracted from what glemy/games/tiers.gleam actually duplicates or hand-rolls. A fresh read of tiers.gleam (post-0049) found three candidates. Investigating the first (generalizing the private clamp_preview_x, which used float.clamp(cursor_x, bounds.min.x + radius, bounds.max.x - radius) to keep the drop preview inside the play area) surfaced a real, verified correctness bug in the naive generalization: on a degenerate box (radius larger than half the box's width -- exactly the scenario physics/bounds_test.gleam's own existing degenerate_zero_size_bounds_still_clamps_without_crashing_test already exercises, point_box with radius 1.0, position (10.0, 10.0)), physics/bounds.gleam's own already-shipped bounce/bounce_axis resolves to 4.0 (confirmed: that test already asserts this and passes), while gleam_stdlib's float.clamp resolves the equivalent 1D case to 6.0 instead (verified directly against gleam_stdlib's source, build/packages/gleam_stdlib/src/gleam/float.gleam:80-85: min_bound >=. max_bound triggers a different branch order than bounce_axis's own first-match-wins case). A second candidate (generalizing the shared bounds.max.y -. margin shape behind spawn_y/danger_line_y into a bounds.y_from_top helper) was investigated and rejected.

Options considered

Implement physics/bounds.clamp_x by delegating directly to gleam/float's clamp — rejected
Verified this would ship a second 'keep a circle inside bounds' function in the same file that silently disagrees with bounce on the exact degenerate input the existing test suite already exercises (4.0 vs. 6.0, confirmed by direct calculation against both implementations, not assumed). Instead gave clamp_x its own private clamp_axis helper using bounce_axis's exact first-branch-wins order, verified by a new test that reuses degenerate_zero_size_bounds_still_clamps_without_crashing_test's own fixture and asserts clamp_x now agrees with bounce (4.0). Did not refactor bounce_axis itself to share code across it -- that function already has 15 passing tests of its own; a small duplicated case block is a safer, smaller diff than touching it.
Add a physics/bounds.y_from_top(bounds, margin) helper generalizing spawn_y/danger_line_y's shared bounds.max.y -. margin shape — rejected
The precedent used to justify extracting things like x_from_canvas_pixel/y_fraction_from_top into physics/bounds.gleam was ARCHITECTURE.md's FFI-minimality rule -- pulling pure math out of Shell so only the genuinely browser-only sliver stays FFI. spawn_y/danger_line_y are already pure Core inside games/tiers.gleam, so there is no Shell code to minimize here; the only remaining justification would be 'the same one-line subtraction appears twice; unlike cooldown.tick's floor-at-zero or clamp_x's degenerate-box handling, there is no branch, floor, or accumulation logic in this shape to get wrong. Extracting it would trade zero-risk duplication for one more name to learn with no testable-logic benefit, the same 'line count is a prompt, not the violation' principle ARCHITECTURE.md's own Modularization thresholds section already states. Left spawn_y/danger_line_y untouched.

Decision

Added physics/bounds.clamp_x(x, radius, bounds) -> Float (keeps a circle's center inside bounds horizontally, position only, no velocity -- for callers like a drop preview that only have a position to keep in bounds, not a simulated entity that should go through bounce instead) plus a private clamp_axis helper matching bounce_axis's exact edge-resolution order. Added physics.settle_all(model, dt) -> Model, a bulk-apply convenience for the exact map-then-reconstruct shape physics.update already uses internally, mirroring that pattern one level up; deliberately kept separate from update itself (update stays the pure, undamped integrate/bounce/collision composition, per its own existing doc comment -- not reopened here). Rewired games/tiers.gleam: deleted the private clamp_preview_x entirely in favor of calling bounds.clamp_x directly at its one call site (matching how tick already calls cooldown.tick/stopwatch.tick/physics.entity_count with no local wrapper, decision 0049's own style), and replaced the 3-line manual settle map-then-reconstruct with one call to physics.settle_all. No Model field or public signature changed shape.

Verification

gleam test and gleam test --target javascript both pass -- 195 Erlang / 218 JavaScript, exactly the prior baseline (186/209) plus the new tests for clamp_x (6) and settle_all (3), with zero change to any pre-existing test file's count, including test/glemy/games/tiers_test.gleam, test/glemy/game_test.gleam, and test/glemy/games/tiers/rules_test.gleam, none of which needed any edit -- their unmodified passing is the regression proof that the tiers.gleam rewiring was behavior-preserving. deno task check-warnings passes with the baseline completely unchanged (51/51, zero new warnings). deno task browser-check passes for real (183 real requestAnimationFrame frames, a real click-and-hold grew the entity count 4 -> 5, a real entity spawned near the expected world-x, zero page errors).

Consequences

physics/bounds.gleam now offers a second entity-shaped-position utility (clamp_x, alongside bounce) that any future game with a cursor-follow or paddle-style mechanic can reuse directly, with a verified, tested guarantee that it agrees with bounce on degenerate input rather than silently diverging. physics.gleam's settle_all closes the gap between update's own internal composition style and what a caller like games/tiers.tick has to hand-write, continuing the same 'tick reads as composition of named Core primitives' improvement decision 0049 started. The rejected bounds.y_from_top candidate is written down here, not silently dropped, so a future reader with a genuinely different (branching/accumulating) shape to extract from that area doesn't have to rediscover why this specific shape wasn't worth it.

References