glemy/physics

2D circle physics – glemy’s genre-agnostic Core API layer, formerly named pe (“Physics Engine & Logic”; renamed in decision 0048 once the “& Logic” half of that name – genre-specific rules – had already moved out to glemy/games/<name>, per decision 0047).

Runs on Gleam/CPU, on both the Erlang and JavaScript targets. Owns only what every 2D circle-physics game genuinely needs: entities, a containing box, gravity, and the per-frame composition of integration, wall-bouncing, and pairwise collision. Anything a specific game decides on top of that – what an entity’s kind means, what should happen when two particular entities meet beyond a plain bounce, win/lose conditions, scoring, input handling beyond “here are some entities and a box” – lives in that game’s own module (glemy’s current one: glemy/games/tiers), not here. See docs/technical-architecture.md’s Core API section for the full reasoning and the stability expectations that come with being “core” (decision 0047).

See glemy/physics/vector2 for the vector math this layer is built on.

Types

The physics world: every entity, the box that contains them, and the constant acceleration (typically gravity) applied to all of them each frame. Deliberately minimal – no score, no win/lose state, no per-game input state. A specific game wraps this in its own state type alongside whatever else it needs (see glemy/games/tiers.Model for the current example) rather than this type growing a field per game that has ever used glemy/physics.

pub type Model {
  Model(
    entities: List(entity.Entity),
    bounds: bounds.Bounds,
    gravity: vector2.Vector2,
  )
}

Constructors

Values

pub fn entity_count(model: Model) -> Int

The number of entities currently in model – named so a caller (e.g. a game’s own spawn-cap check) doesn’t need to reach into model.entities directly for something this small and likely-repeated. Deliberately not a has_capacity_for-style helper: a specific game’s own cap (e.g. glemy/games/tiers.max_entities) is that game’s tuning constant, not something this genre-agnostic layer should own or compare against.

pub const max_dt: Float

The largest dt a single update call should ever actually integrate with, regardless of how much real wall-clock time a caller reports – a real stall (a GC pause, a backgrounded browser tab regaining focus, a slow device) can and does report a dt far larger than one real frame’s worth, from any game’s own per-frame loop, not just glemy’s current one.

This isn’t the “spiral of death” accumulator clamp from the classic fixed-timestep game-loop pattern (commonly ~0.25s, e.g. Glenn Fiedler’s “Fix Your Timestep!”) – there’s no fixed-step accumulator here; update integrates once, directly, with whatever dt it’s handed. A far smaller cap is what this specific architecture needs: entity-entity collision here is discrete (collision.overlap only ever compares each frame’s end positions, never sweeps the path between them), so an unreasonably large single-frame dt can let a fast-moving entity’s integrated position land cleanly on the far side of another entity it should have collided with, with no frame in between where they ever actually overlapped – a real, confirmed tunneling risk (not hypothetical – reproduced directly via a synthetic large dt). Clamping instead trades perfect real-time pacing during a genuine stall for the simulation effectively running in bounded slow-motion through it – the standard, accepted tradeoff for exactly this failure mode.

Deliberately not applied inside update itself – update stays the pure, unclamped integrate/bounce/collision composition (so its own tests can exercise arbitrary dt values directly); a caller that wants this protection applies float.min(dt, physics.max_dt) before calling update, the same way glemy/games/tiers’s own tick function does. 1.0 /. 30.0: half of a real 60fps frame’s rate, small enough to stay safe for any two entities whose radii and speeds are in roughly the same range as glemy’s own reference game tunes for – a different game with much faster entities or much smaller radii should re-derive this for its own numbers rather than assume it transfers unchanged. See decision 0037.

pub fn settle_all(model: Model, dt: Float) -> Model

Applies entity.settle (damping/rest-snapping) to every entity in model. The bulk-apply convenience for the exact map-then-reconstruct shape update already uses internally, one level up. Deliberately separate from update – see that function’s own doc comment for why settling isn’t folded into it. See decision 0050.

pub fn spawn_entity(model: Model, entity: entity.Entity) -> Model

Adds a new entity to model – prepended (not appended) since that’s O(1) on a Gleam List, and spawn order has no bearing on simulation correctness (update resolves every pair regardless of list order). Plain enough that a caller could do this inline (Model(..model, entities: [entity, ..model.entities])); kept as a named function so that isn’t a detail every caller needs to rediscover.

pub fn update(
  model: Model,
  dt: Float,
  wall_behavior: fn(entity.Entity, bounds.Bounds) -> entity.Entity,
  interact: fn(entity.Entity, entity.Entity) -> collision_sweep.PairInteraction(
    event,
  ),
) -> #(Model, List(event))

Advances the whole simulation by dt seconds, in three passes: first every entity is integrated under gravity and passed through wall_behavior against bounds, then every pair of entities is checked for a collision, resolved via interact (see glemy/physics/collision_sweep) – a plain bounce unless interact decides otherwise. This is the one place glemy/physics’s pieces (entity, bounds, collision_sweep) get composed into an actual per-frame update; a specific game’s own tick function calls this once per frame alongside whatever else it needs to do (spawning, scoring, checking its own win/lose conditions – see glemy/games/tiers.tick for the current example).

wall_behavior is caller-supplied rather than hardcoded to bounds.bounce, the same reasoning interact already applies to pairwise collision (decision 0047): bounds.bounce’s own restitution ramp is tuned specifically for games/tiers (decision 0029), and a second genre-distinct game (Breakout, decision 0052) confirmed directly that a hardcoded wall step makes update unusable by any game wanting different wall behavior at all – rather than shrink update over that finding, decision 0062 made the wall step pluggable instead, mirroring interact’s own already-proven shape. games/tiers.gleam passes bounds.bounce explicitly now; a future List(Entity)-shaped game with its own wall policy can pass its own fn(Entity, Bounds) -> Entity instead, built from bounds.resolve_axis/resolve_high_only the same way games/breakout.gleam/games/platformer.gleam already do.

Returns whatever events interact produced this frame (e.g. a score delta per merge) alongside the new Model, always freshly computed from this one call – glemy/physics itself has no concept of what an event even is, only that interact may produce zero or more of them.

Search Document