glemy/physics/bounds
Types
An axis-aligned box, defined by its minimum and maximum corners.
min.x must be less than max.x (and likewise for y) — this is not
validated, it’s on the caller to construct a sensible box.
pub type Bounds {
Bounds(min: vector2.Vector2, max: vector2.Vector2)
}
Constructors
-
Bounds(min: vector2.Vector2, max: vector2.Vector2)
Values
pub fn bounce(
entity: entity.Entity,
bounds: Bounds,
) -> entity.Entity
Keeps an entity’s visible edge inside bounds, bouncing it off any
edge it has crossed: position is clamped so the circle’s rendered
boundary never passes bounds (its center is clamped to
radius inside each wall, not to the wall itself), and the velocity
component perpendicular to that edge is reflected to point back
inside — at full (elastic) strength for a genuine, higher-speed
impact, or proportionally softened for a gentle one (see
genuine_impact_velocity). The two axes are resolved independently,
so a corner is just both axes bouncing at once.
bounce(
Entity(position: Vector2(0.5, 0.5), velocity: Vector2(-20.0, 0.0), radius: 1.0, kind: 0),
Bounds(min: Vector2(0.0, 0.0), max: Vector2(10.0, 10.0)),
)
// -> Entity(position: Vector2(1.0, 0.5), velocity: Vector2(20.0, 0.0), radius: 1.0, kind: 0)
pub fn clamp_x(x: Float, radius: Float, bounds: Bounds) -> Float
Clamps x so a circle of radius centered at it stays fully inside
bounds horizontally – position only, no velocity (an entity
that’s actually simulated should go through bounce, which also
reflects velocity off any edge crossed; this is for a caller that
only has a position to keep in bounds, e.g. a drop preview).
Uses the same first-branch-wins edge resolution as bounce_axis
(see clamp_axis), not gleam/float’s own clamp – on a
degenerate box (radius larger than half the box’s width) the two
disagree (verified directly against both implementations), and this
file’s two “keep a circle inside bounds” functions should resolve a
degenerate box identically, not diverge depending on which a caller
happens to reach for. See decision 0050.
clamp_x(
50.0,
1.0,
Bounds(min: Vector2(0.0, 0.0), max: Vector2(10.0, 10.0)),
)
// -> 9.0
pub const genuine_impact_velocity: Float
Reference impact speed at/above which a wall “hit” reflects at full
restitution strength (not full 1.0 – see that constant). Below
it, the reflected speed is scaled down further, proportionally to
how far below this reference the incoming speed
is (incoming *. min(1.0, incoming /. genuine_impact_velocity)) —
not a hard “below X, reflect at 0; at/above X, reflect at full
speed” cutoff.
A hard cutoff was tried first and shipped, then found — by directly,
repeatedly measuring a real two-entity stack over many seconds, not
just by gleam test passing — to have a serious flaw: whatever the
cutoff value, the system can settle into a perfectly periodic,
never-decaying bounce with its impact speed sitting just above
that exact value forever. This isn’t specific to any one cutoff
(both 3.0 and a Box2D-gravity-ratio-derived 10.0 exhibited it,
confirmed empirically) — a hard cutoff is a discontinuity, and nothing
stops the dynamics from finding a stable point balanced right on top
of it, since a bounce that lands at or above the cutoff keeps its
full, energy-preserving elastic reflection no matter how close it is
to the line. A smooth ramp has no such line to balance on: strictly
below genuine_impact_velocity, every single bounce returns less
speed than it received (quadratically less, in fact —
incoming² /. genuine_impact_velocity — not just a fixed fraction),
so a resonance can’t stay at a fixed amplitude; it’s mathematically
forced to shrink every cycle until entity.rest_velocity_threshold’s
cleanup snap finishes the job. See docs/decisions.jsonl, decisions
0027 and 0028 for the full “balls gaining excessive momentum and
never becoming stationary” investigation this constant is the
second, corrected fix for.
pub fn resolve_axis(
position: Float,
velocity: Float,
min: Float,
max: Float,
on_low: fn(Float) -> Float,
on_high: fn(Float) -> Float,
) -> #(Float, Float)
Resolves a single axis: clamps position into [min, max] and, if
it was out of range, applies on_low/on_high to velocity to
decide what happens on that edge – reflect at some strength, stop
dead, or anything else a caller needs. Originally private inside
this module as bounce_axis, hardcoded to a Tiers-specific
restitution ramp (reflected_speed, below); promoted to a public,
caller-parameterized primitive once games/breakout.gleam’s
bounce_axis_full and games/platformer.gleam’s stop_axis were
found to independently reimplement the exact same clamp-and-branch
shape, differing only in what the velocity transform does on each
edge – the same caller-supplied-callback generalization
collision_sweep’s interact already established for pairwise
collision (decision 0047), applied here to a wall instead of another
entity. bounce (below) is now just one particular choice of
on_low/on_high, not a hardcoded special case.
resolve_axis(11.0, 5.0, 0.0, 10.0, float.negate, float.negate)
// -> #(10.0, -5.0)
pub fn resolve_high_only(
position: Float,
velocity: Float,
max: Float,
on_high: fn(Float) -> Float,
) -> #(Float, Float)
Same shape as resolve_axis, restricted to the high edge only –
for a caller whose low edge is deliberately not a wall at all (e.g.
games/breakout.gleam’s and games/platformer.gleam’s own bottom
edge, where falling past it is a lose trigger, not something to
bounce or stop against). Extracted once both games independently
wrote this exact restricted shape (bounce_top_only/
stop_at_ceiling), matching resolve_axis’s own promotion
reasoning.
resolve_high_only(11.0, 5.0, 10.0, float.negate)
// -> #(10.0, -5.0)
pub const restitution: Float
How much of a genuine, higher-speed wall impact’s closing velocity is
returned as bounce-back – same value, and same reasoning, as
glemy/physics/collision’s restitution: a real headless-browser trace
with a fully elastic (1.0) wall bounce showed an entity rebounding
off the floor by ~16 world units (16% of the 100-unit play area) on
its first bounce alone. 0.1 matches a real, working open-source
clone of the reference game (TomboFry/suika-game, Matter.js-based).
See decision 0029.
pub fn x_fraction(world_x: Float, bounds: Bounds) -> Float
The x-fraction (0.0 at bounds.min.x, 1.0 at bounds.max.x) of
world_x within bounds – y_fraction_from_top’s x-axis sibling,
a plain linear rescale in the same direction as x_from_canvas_pixel
(increasing world_x maps to an increasing fraction, unlike the y
axis’s inverted convention). Originally kept game-local to
glemy/game_breakout (exactly one real caller); promoted here once
glemy/game_platformer needed the identical rescale for its own CSS
overlay. Not clamped, same reasoning as x_from_canvas_pixel.
x_fraction(75.0, Bounds(min: Vector2(0.0, 0.0), max: Vector2(100.0, 100.0)))
// -> 0.75
pub fn x_from_canvas_pixel(
pixel_x: Float,
canvas_width: Float,
bounds: Bounds,
) -> Float
Converts a canvas-local pixel x-coordinate (already made relative to
the canvas’s own left edge – see glemy/game_tiers’s loop, which
subtracts getBoundingClientRect().left before calling this) into a
world-space x-coordinate inside bounds, by a plain linear rescale:
0.0 maps to bounds.min.x, canvas_width maps to bounds.max.x.
canvas_width must be in the same pixel space as pixel_x — the
canvas’s displayed (CSS) width, not its internal pixel-buffer
width, since that’s what a real click’s coordinates are measured in.
x_from_canvas_pixel(
256.0,
512.0,
Bounds(min: Vector2(0.0, 0.0), max: Vector2(100.0, 100.0)),
)
// -> 50.0
pub fn y_fraction_from_top(
world_y: Float,
bounds: Bounds,
) -> Float
The reverse of x_from_canvas_pixel, but for y, and expressed as a
0.0-1.0 fraction (not a pixel) – what a CSS top: N% overlay
needs, since it has no notion of the canvas’s own pixel buffer size.
bounds.max.y (the world “top”, since gravity pulls toward smaller
y – see glemy/physics.gleam’s gravity/spawn conventions) maps to
0.0; bounds.min.y maps to 1.0. Not clamped – a world_y
outside bounds extrapolates linearly, same reasoning as
x_from_canvas_pixel_does_not_clamp_out_of_range_pixels_test.
y_fraction_from_top(
95.0,
Bounds(min: Vector2(0.0, 0.0), max: Vector2(100.0, 100.0)),
)
// -> 0.05