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

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 boundsy_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
Search Document