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
-
Model( entities: List(entity.Entity), bounds: bounds.Bounds, gravity: vector2.Vector2, )
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.