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

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:

// 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

  • UseBVH

    Use BVH for dynamic scenes (default)

  • UseGrid(cell_size: Float)

    Use Grid for uniform distributions (particle systems, crowds)

  • AutoSelect

    Auto-select based on body distribution and count

A 3D physics world with spatial acceleration

pub opaque type World(id)

Values

pub fn add_body(
  world: World(id),
  body: body.Body(id),
) -> World(id)

Add a body to the world

pub fn bodies(world: World(id)) -> dict.Dict(id, body.Body(id))

Get all bodies in the world

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 remove_body(world: World(id), id: id) -> World(id)

Remove a body from the world

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