expresso/world
2D physics world management with intelligent spatial acceleration.
This module provides the World type that manages a collection of rigid bodies
and simulates physics each step using position-based dynamics.
Features
- Dual spatial acceleration: BVH for dynamic scenes, Grid for particles
- Spatial queries: Find bodies by radius, region, or nearest neighbor
- Auto-selection: Intelligently chooses BVH or Grid based on body distribution
- Deterministic: Same inputs always produce same outputs
Quick Start
import expresso/world
import expresso/body
import vec/vec2
// Create a world with gravity
let world = world.new(gravity: vec2.Vec2(0.0, -9.8))
// Add bodies
let world = world
|> world.add_body(body.new_circle("ball", vec2.Vec2(0.0, 5.0), radius: 0.5))
|> world.add_body(body.new_box("platform", vec2.Vec2(0.0, 0.0), 5.0, 0.5))
// Configure solver
let world = world
|> world.with_iterations(10)
|> world.with_restitution(0.5)
// Step physics each frame
let world = world.step(world, delta_time: 0.016)
Spatial Acceleration
Choose the best strategy for your use case:
- UseBVH - Best for dynamic scenes with moving objects (default)
- UseGrid - Best for uniform particle systems (1000s of particles)
- AutoSelect - Automatically picks BVH or Grid based on distribution
// Force BVH for dynamic game
let world = world.with_spatial_strategy(world, world.UseBVH)
// Force Grid for particle system
let world = world.with_spatial_strategy(world, world.UseGrid(cell_size: 2.0))
Spatial Queries
Find bodies efficiently using spatial queries:
// Find all bodies near a point (e.g., explosion radius)
let nearby = world.query_radius(world, center: pos, radius: 5.0)
// Find all bodies in a rectangle (e.g., screen culling)
let visible = world.query_region(world, min: vec2.Vec2(-10.0, -10.0),
max: vec2.Vec2(10.0, 10.0))
// Find nearest body (e.g., target acquisition)
let nearest = world.query_nearest(world, point: player_pos)
Types
Collision event types
pub type CollisionEvent(id) {
CollisionStarted(body_a: id, body_b: id)
CollisionEnded(body_a: id, body_b: id)
TriggerEntered(body: id, trigger: id)
TriggerExited(body: id, trigger: id)
}
Constructors
-
CollisionStarted(body_a: id, body_b: id)A collision between two bodies started this frame
-
CollisionEnded(body_a: id, body_b: id)A collision between two bodies ended this frame
-
TriggerEntered(body: id, trigger: id)A body entered a trigger zone
-
TriggerExited(body: id, trigger: id)A body exited a trigger zone
Represents a collision pair (unordered)
pub type CollisionPair(id) {
CollisionPair(a: id, b: id)
}
Constructors
-
CollisionPair(a: id, b: id)
Spatial partitioning strategy
pub type SpatialStrategy {
UseBVH
UseGrid(cell_size: Float)
AutoSelect
}
Constructors
-
UseBVHUse BVH for dynamic scenes (default)
-
UseGrid(cell_size: Float)Use Grid for uniform distributions (particle systems, crowds)
-
AutoSelectAuto-select based on body distribution and count
Values
pub fn get_body(
world: World(id),
id: id,
) -> Result(body.Body(id), Nil)
Get a body from the world
pub fn new(gravity gravity: vec3.Vec3(Float)) -> World(id)
Create a new empty physics world
Example
let world = world.new(
gravity: vec3.Vec3(0.0, -9.8, 0.0), // Earth gravity pointing down
)
pub fn query_nearest(
world: World(id),
point point: vec3.Vec3(Float),
) -> option.Option(#(id, body.Body(id), Float))
Find the nearest body to a point
Example
case world.query_nearest(world, point: vec3.Vec3(5.0, 5.0, 5.0)) {
Some(#(id, body, distance)) -> // Use nearest body
None -> // No bodies in world
}
pub fn query_radius(
world: World(id),
center center: vec3.Vec3(Float),
radius radius: Float,
) -> List(#(id, body.Body(id)))
Query bodies within a radius of a point
Uses spatial acceleration when available.
Example
let nearby = world.query_radius(world, center: vec3.Vec3(0.0, 0.0, 0.0), radius: 5.0)
pub fn query_region(
world: World(id),
min min: vec3.Vec3(Float),
max max: vec3.Vec3(Float),
) -> List(#(id, body.Body(id)))
Query bodies within a rectangular region
Example
let bodies_in_region = world.query_region(
world,
min: vec3.Vec3(-10.0, -10.0, -10.0),
max: vec3.Vec3(10.0, 10.0, 10.0),
)
pub fn raycast(
world: World(id),
origin origin: vec3.Vec3(Float),
direction direction: vec3.Vec3(Float),
max_distance max_distance: Float,
layer_mask layer_mask: option.Option(Int),
) -> option.Option(collision.RaycastHit(id))
Cast a ray and find the first body it hits
Returns detailed information about the hit including:
- The body that was hit
- The exact hit point
- The surface normal at the hit point
- The distance from the ray origin
Optionally filter by collision layers using the layer_mask parameter.
Example
// Shoot a laser forward
case world.raycast(
world,
origin: player_pos,
direction: vec3.Vec3(1.0, 0.0, 0.0),
max_distance: 100.0,
layer_mask: option.None, // Hit all layers
) {
Some(hit) -> {
// Hit something!
io.println("Hit " <> hit.body.id <> " at distance " <> float.to_string(hit.distance))
}
None -> {
// No hit
}
}
// Only hit enemies
case world.raycast(
world,
origin: player_pos,
direction: aim_direction,
max_distance: 50.0,
layer_mask: option.Some(body.layer_enemy),
) {
Some(hit) -> damage_enemy(hit.body_id)
None -> {}
}
pub fn step(
world world: World(id),
delta_time delta_time: Float,
) -> #(World(id), List(CollisionEvent(id)))
Simulate one physics timestep using substeps
This is the main physics update function. Call it each game tick with delta_time. Returns the updated world and a list of collision events that occurred.
The timestep is divided into world.substeps smaller steps, with collision
detection happening between each substep. This fixes chain collisions where
forces need to propagate through multiple bodies (e.g., Enemy1 → Enemy2 → Enemy3).
Example
let #(world, events) = world.step(world, delta_time: 1.0 /. 60.0) // 60 FPS
// Handle collision events
list.each(events, fn(event) {
case event {
CollisionStarted(a, b) -> io.println("Collision started!")
TriggerEntered(body, trigger) -> io.println("Entered trigger zone!")
_ -> Nil
}
})
pub fn update_body(
world: World(id),
id: id,
update: fn(body.Body(id)) -> body.Body(id),
) -> World(id)
Update a body in the world
pub fn with_iterations(
world: World(id),
iterations: Int,
) -> World(id)
Set the number of solver iterations
More iterations = more stable but slower. 3-5 is typical, 10+ for very stable stacks.
pub fn with_restitution(
world: World(id),
restitution: Float,
) -> World(id)
Set the coefficient of restitution (bounciness)
0.0 = perfectly inelastic (no bounce) 1.0 = perfectly elastic (full bounce)
pub fn with_spatial_strategy(
world: World(id),
strategy: SpatialStrategy,
) -> World(id)
Set the spatial partitioning strategy
UseBVH: Best for dynamic scenes with moving objects (default)UseGrid(cell_size): Best for uniform distributions (particles, crowds)AutoSelect: Automatically choose based on body count
Example
// Force BVH for dynamic game
let world = world.with_spatial_strategy(world, UseBVH)
// Use grid for particle system
let world = world.with_spatial_strategy(world, UseGrid(cell_size: 2.0))
pub fn with_substeps(
world: World(id),
substeps: Int,
) -> World(id)
Set the number of substeps per physics step
Substeps divide each physics step into smaller timesteps, re-detecting collisions between each substep. This fixes chain collisions where forces need to propagate through multiple bodies.
More substeps = more accurate but slower. Typical values: 2-3 for games, 4+ for complex chains.
Example
// Handle chains of 10+ enemies pushing each other
let world = world.new(gravity: vec2.Vec2(0.0, 0.0))
|> world.with_substeps(3)