tiramisu/physics

3D physics simulation using the Rapier physics engine.

Tiramisu uses Rapier for realistic physics with rigid bodies, colliders, forces, raycasting, and collision detection. Physics is opt-in per scene node.

Setting Up Physics

import tiramisu/physics
import vec/vec3
import gleam/option

fn init(ctx: Context) {
  let world = physics.new_world(physics.WorldConfig(
    gravity: vec3.Vec3(0.0, -9.81, 0.0),
  ))

  #(Model(physics_world: world), effect.dispatch(Tick), option.Some(world))
}

fn update(model: Model, msg: Msg, ctx: Context) {
  case msg {
    Tick -> {
      let world = physics.step(model.physics_world, ctx.delta_time)
      #(Model(..model, physics_world: world), effect.dispatch(Tick), option.None)
    }
  }
}

Creating Rigid Bodies

// Dynamic ball that responds to physics
let ball = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Sphere(
    offset: transform.identity,
    radius: 1.0,
  ))
  |> physics.with_mass(5.0)
  |> physics.with_restitution(0.8)  // Bouncy
  |> physics.build()

// Static ground
let ground = physics.new_rigid_body(physics.Fixed)
  |> physics.with_collider(physics.Box(
    offset: transform.identity,
    size: vec3.Vec3(50.0, 1.0, 50.0),
  ))
  |> physics.build()

Attaching Physics to Scene Nodes

scene.mesh(
  id: "ball",
  geometry: geometry.sphere(radius: 1.0, segments: vec2.Vec2(32.0, 32.0)),
  material: material.standard() |> material.with_color(0xFF0000),
  transform: transform.at(position: vec3.Vec3(0.0, 10.0, 0.0)),
  physics: option.Some(ball),  // Physics attached here
)

Applying Forces and Impulses

// Apply force (continuous, like wind)
let world = physics.apply_force(world, "player", vec3.Vec3(100.0, 0.0, 0.0))

// Apply impulse (instant, like a jump)
let world = physics.apply_impulse(world, "player", vec3.Vec3(0.0, 10.0, 0.0))

// Set velocity directly
let world = physics.set_velocity(world, "player", vec3.Vec3(5.0, 0.0, 0.0))

Raycasting

case physics.raycast(world, origin, direction, max_distance: 100.0) {
  Ok(hit) -> {
    io.println("Hit " <> hit.id <> " at distance " <> float.to_string(hit.distance))
  }
  Error(Nil) -> // No hit
}

Collision Events

Enable collision tracking and query events each frame:

// Enable on body
physics.with_collision_events()

// Query in update
list.each(physics.get_collision_events(world), fn(event) {
  case event {
    physics.CollisionStarted(a, b) -> // Bodies started touching
    physics.CollisionEnded(a, b) -> // Bodies stopped touching
  }
})

Types

Axis locks for restricting body movement/rotation

pub type AxisLock {
  AxisLock(
    lock_translation_x: Bool,
    lock_translation_y: Bool,
    lock_translation_z: Bool,
    lock_rotation_x: Bool,
    lock_rotation_y: Bool,
    lock_rotation_z: Bool,
  )
}

Constructors

  • AxisLock(
      lock_translation_x: Bool,
      lock_translation_y: Bool,
      lock_translation_z: Bool,
      lock_rotation_x: Bool,
      lock_rotation_y: Bool,
      lock_rotation_z: Bool,
    )

    Arguments

    lock_translation_x

    Lock translation on X axis

    lock_translation_y

    Lock translation on Y axis

    lock_translation_z

    Lock translation on Z axis

    lock_rotation_x

    Lock rotation on X axis

    lock_rotation_y

    Lock rotation on Y axis

    lock_rotation_z

    Lock rotation on Z axis

Physics body type

pub type Body {
  Dynamic
  Kinematic
  Fixed
}

Constructors

  • Dynamic

    Dynamic bodies are affected by forces and gravity

  • Kinematic

    Kinematic bodies can be moved programmatically but don’t respond to forces

  • Fixed

    Fixed (static) bodies don’t move

Character controller configuration for kinematic character movement.

Character controllers provide collision-aware movement for kinematic bodies, perfect for player characters, NPCs, and moving platforms.

Example

physics.CharacterController(
  offset: 0.01,
  up_vector: vec3.Vec3(0.0, 1.0, 0.0),
  slide_enabled: True,
)
pub type CharacterController {
  CharacterController(
    offset: Float,
    up_vector: vec3.Vec3(Float),
    slide_enabled: Bool,
  )
}

Constructors

  • CharacterController(
      offset: Float,
      up_vector: vec3.Vec3(Float),
      slide_enabled: Bool,
    )

    Arguments

    offset

    Offset from surfaces (default: 0.01)

    up_vector

    Up vector for the character (default: positive Y)

    slide_enabled

    Enable sliding along surfaces (default: True)

Collider shape

pub type ColliderShape {
  Box(offset: transform.Transform, size: vec3.Vec3(Float))
  Sphere(offset: transform.Transform, radius: Float)
  Capsule(
    offset: transform.Transform,
    half_height: Float,
    radius: Float,
  )
  Cylinder(
    offset: transform.Transform,
    half_height: Float,
    radius: Float,
  )
}

Constructors

Collision events that occurred during the physics step

pub type CollisionEvent {
  CollisionStarted(body_a: String, body_b: String)
  CollisionEnded(body_a: String, body_b: String)
}

Constructors

  • CollisionStarted(body_a: String, body_b: String)

    Two bodies started colliding

  • CollisionEnded(body_a: String, body_b: String)

    Two bodies stopped colliding

Collision groups for filtering which objects can collide with each other.

Uses Rapier’s collision groups system based on 32-bit bitmasks:

  • membership: What collision layers this body belongs to (0-15)
  • filter: What collision layers this body can interact with (0-15)

Two bodies can collide only if:

  • Body A’s membership overlaps with Body B’s filter, AND
  • Body B’s membership overlaps with Body A’s filter

Example

// Player belongs to layer 0, collides with layers 1 (enemies) and 2 (ground)
let player_groups = physics.CollisionGroups(
  membership: [0],
  filter: [1, 2]
)

// Enemy belongs to layer 1, collides with layers 0 (player) and 3 (projectiles)
let enemy_groups = physics.CollisionGroups(
  membership: [1],
  filter: [0, 3]
)

// Ground belongs to layer 2, collides with everything
let ground_groups = physics.CollisionGroups(
  membership: [2],
  filter: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
)
pub type CollisionGroups {
  CollisionGroups(membership: List(Int), filter: List(Int))
}

Constructors

  • CollisionGroups(membership: List(Int), filter: List(Int))

    Arguments

    membership

    List of collision layers this body belongs to (0-15)

    filter

    List of collision layers this body can collide with (0-15)

Opaque handle to the Rapier physics world This is part of your Model and gets updated each frame

pub opaque type PhysicsWorld

Result of a raycast hit

pub type RaycastHit {
  RaycastHit(
    id: String,
    point: vec3.Vec3(Float),
    normal: vec3.Vec3(Float),
    distance: Float,
  )
}

Constructors

  • RaycastHit(
      id: String,
      point: vec3.Vec3(Float),
      normal: vec3.Vec3(Float),
      distance: Float,
    )

    Arguments

    id

    ID of the body that was hit

    point

    Point where the ray intersected the body

    normal

    Normal vector at the hit point

    distance

    Distance from ray origin to hit point

Physics body configuration

pub opaque type RigidBody

Builder for creating rigid bodies with a fluent API

pub opaque type RigidBodyBuilder(a)
pub type WithCollider
pub type WithoutCollider

Physics world configuration

pub type WorldConfig {
  WorldConfig(gravity: vec3.Vec3(Float))
}

Constructors

  • WorldConfig(gravity: vec3.Vec3(Float))

    Arguments

    gravity

    Gravity vector (typically Vec3(0.0, -9.81, 0.0))

Values

pub fn apply_force(
  world: PhysicsWorld,
  id: String,
  force: vec3.Vec3(Float),
) -> PhysicsWorld

Queue a force to be applied to a rigid body during the next physics step. Returns updated world with the command queued.

Example

let world = physics.apply_force(world, "player", vec3.Vec3(0.0, 100.0, 0.0))
let world = physics.step(world, ctx.delta_time)  // Force is applied here
pub fn apply_impulse(
  world: PhysicsWorld,
  id: String,
  impulse: vec3.Vec3(Float),
) -> PhysicsWorld

Queue an impulse to be applied to a rigid body during the next physics step. Returns updated world with the command queued.

Example

// Jump
let world = physics.apply_impulse(world, "player", vec3.Vec3(0.0, 10.0, 0.0))
pub fn apply_torque(
  world: PhysicsWorld,
  id: String,
  torque: vec3.Vec3(Float),
) -> PhysicsWorld

Queue a torque to be applied to a rigid body during the next physics step. Returns updated world with the command queued.

pub fn apply_torque_impulse(
  world: PhysicsWorld,
  id: String,
  impulse: vec3.Vec3(Float),
) -> PhysicsWorld

Queue a torque impulse to be applied to a rigid body during the next physics step. Returns updated world with the command queued.

pub fn build(
  builder: RigidBodyBuilder(WithCollider),
) -> RigidBody

Build the final rigid body from the builder.

This function is type-safe - you cannot call it without first calling with_collider().

Example

let body = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Sphere(transform.identity, 1.0))
  |> physics.with_mass(5.0)
  |> physics.build()  // Returns RigidBody ready to use
pub fn compute_character_movement(
  world: PhysicsWorld,
  id: String,
  desired_translation: vec3.Vec3(Float),
) -> Result(vec3.Vec3(Float), Nil)

Compute collision-aware movement for a kinematic character. Returns the actual movement that can be safely applied without penetrating colliders. Must have created a character controller for this body first.

pub fn get_angular_velocity(
  world: PhysicsWorld,
  id: String,
) -> Result(vec3.Vec3(Float), Nil)

Get the current angular velocity of a rigid body

pub fn get_collision_events(
  world: PhysicsWorld,
) -> List(CollisionEvent)

Get all collision events that occurred during the last physics step.

Events are automatically collected when step() is called and stored in the world.

Example

let physics_world = physics.step(physics_world)
let collision_events = physics.get_collision_events(physics_world)

list.each(collision_events, fn(event) {
  case event {
    physics.CollisionStarted(a, b) ->
      io.println(a <> " started colliding with " <> b)
    physics.CollisionEnded(a, b) ->
      io.println(a <> " ended colliding with " <> b)
  }
})
pub fn get_transform(
  physics_world: PhysicsWorld,
  id: String,
) -> Result(transform.Transform, Nil)

Get the current transform of a rigid body.

Queries the physics simulation directly, so it always returns the latest position even for bodies that were just created in the current frame.

Example

let cube_transform = case physics.get_transform(physics_world, Cube1) {
  Ok(t) -> t
  Error(_) -> transform.at(position: vec3.Vec3(0.0, 10.0, 0.0))
}
pub fn get_velocity(
  world: PhysicsWorld,
  id: String,
) -> Result(vec3.Vec3(Float), Nil)

Get the current velocity of a rigid body

pub fn is_character_grounded(
  world: PhysicsWorld,
  id: String,
) -> Result(Bool, Nil)

Check if a character is grounded (on the ground). This uses the character controller’s computed grounded state. Must have called compute_character_movement for this body first in this frame.

pub fn new_rigid_body(
  body_type: Body,
) -> RigidBodyBuilder(WithoutCollider)

Create a new rigid body builder.

Start here to build a physics body using the fluent builder pattern. You must call with_collider() before build().

Body Types:

  • Dynamic: Moves and responds to forces (balls, characters, props)
  • Kinematic: Programmatically controlled, doesn’t respond to forces (elevators, doors)
  • Fixed: Static, immovable (walls, floors, terrain)

Example

import tiramisu/physics
import tiramisu/transform

// Dynamic ball
let ball = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Sphere(
    offset: transform.identity,
    radius: 1.0,
  ))
  |> physics.with_mass(5.0)
  |> physics.with_restitution(0.8)
  |> physics.build()

// Static ground
let ground = physics.new_rigid_body(physics.Fixed)
  |> physics.with_collider(physics.Box(
    offset: transform.identity,
    width: 50.0,
    height: 1.0,
    depth: 50.0,
  ))
  |> physics.build()
pub fn new_world(config: WorldConfig) -> PhysicsWorld

Create a new physics world.

Call this in your init() function and store the world in your Model. Return it as the third element of the init triple so Tiramisu can manage it.

Gravity: Typical Earth gravity is Vec3(0.0, -9.81, 0.0). Use Vec3(0.0, 0.0, 0.0) for zero-gravity space games.

Example

import tiramisu/physics
import vec/vec3
import gleam/option

type Model {
  Model(physics_world: physics.PhysicsWorld(String))
}

fn init(ctx) {
  let world = physics.new_world(physics.WorldConfig(
    gravity: vec3.Vec3(0.0, -9.81, 0.0),  // Earth gravity
  ))

  #(
    Model(physics_world: world),
    effect.none(),
    option.Some(world),  // Return world for Tiramisu to manage
  )
}
pub fn raycast(
  world: PhysicsWorld,
  origin origin: vec3.Vec3(Float),
  direction direction: vec3.Vec3(Float),
  max_distance max_distance: Float,
) -> Result(RaycastHit, Nil)

Cast a ray and return the first hit

Useful for shooting mechanics, line-of-sight checks, and ground detection.

Example

// Cast ray downward from player position
let origin = player_position
let direction = vec3.Vec3(0.0, -1.0, 0.0)

case physics.raycast(world, origin, direction, max_distance: 10.0) {
  Ok(hit) -> {
    // Found ground at hit.distance units below player
    io.println("Hit body with ID")
  }
  Error(Nil) -> {
    // No ground found within 10 units
  }
}
pub fn set_angular_velocity(
  world: PhysicsWorld,
  id: String,
  velocity: vec3.Vec3(Float),
) -> PhysicsWorld

Queue an angular velocity change for a rigid body during the next physics step. Returns updated world with the command queued.

pub fn set_kinematic_translation(
  world: PhysicsWorld,
  id: String,
  position: vec3.Vec3(Float),
) -> PhysicsWorld

Queue a kinematic translation change for a kinematic rigid body during the next physics step. This is the proper way to move kinematic bodies in Rapier. Returns updated world with the command queued.

pub fn set_velocity(
  world: PhysicsWorld,
  id: String,
  velocity: vec3.Vec3(Float),
) -> PhysicsWorld

Queue a velocity change for a rigid body during the next physics step. Returns updated world with the command queued.

pub fn step(
  world: PhysicsWorld,
  delta_time: duration.Duration,
) -> PhysicsWorld

Step the physics simulation forward with variable timestep This should be called in your update function each frame

IMPORTANT: Pass ctx.delta_time for frame-rate independent physics!

Example

fn update(model, msg, ctx) {
  let world = physics.step(model.physics_world, ctx.delta_time)
  #(Model(..model, physics_world: world), effect.none(), option.None)
}

Returns updated world with new transforms for all bodies

pub fn with_angular_damping(
  builder: RigidBodyBuilder(a),
  damping: Float,
) -> RigidBodyBuilder(b)

Set angular damping (air resistance for rotation).

Damping: 0.0 = no resistance, higher = more drag Prevents bodies from spinning forever. Default: 0.0

pub fn with_body_ccd_enabled(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Enable Continuous Collision Detection (CCD).

CCD prevents fast-moving objects from tunneling through thin obstacles. Use for bullets, fast-moving balls, or high-velocity objects.

Example

// Bullet that shouldn't pass through walls
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Sphere(transform.identity, 0.1))
  |> physics.with_body_ccd_enabled()
pub fn with_character_controller(
  builder: RigidBodyBuilder(a),
  offset offset: Float,
  up_vector up_vector: vec3.Vec3(Float),
  slide_enabled slide_enabled: Bool,
) -> RigidBodyBuilder(b)

Add a character controller for collision-aware kinematic movement.

Character controllers are perfect for player characters and NPCs, providing:

  • Automatic collision detection and response
  • Sliding along surfaces
  • Configurable offset from obstacles

Note: Character controllers are only useful for Kinematic bodies.

Example

// Player character with character controller
let player = physics.new_rigid_body(physics.Kinematic)
  |> physics.with_collider(physics.Capsule(
    offset: transform.identity,
    half_height: 0.9,
    radius: 0.3,
  ))
  |> physics.with_character_controller(
    offset: 0.01,
    up_vector: vec3.Vec3(0.0, 1.0, 0.0),
    slide_enabled: True,
  )
  |> physics.build()
pub fn with_collider(
  builder: RigidBodyBuilder(a),
  collider: ColliderShape,
) -> RigidBodyBuilder(WithCollider)
pub fn with_collision_events(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Enable collision event tracking for this body.

By default, collision events are not tracked to minimize performance overhead. Call this method if you need to receive collision events via get_collision_events().

Performance Note: Only enable collision events for bodies where you actually need to detect collisions (e.g., player, enemies, collectibles). Static decorations and particle effects typically don’t need event tracking.

Example

// Player needs collision events (e.g., for damage detection)
let player = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Capsule(
    offset: transform.identity,
    half_height: 0.9,
    radius: 0.3,
  ))
  |> physics.with_collision_events()  // Enable events
  |> physics.build()

// Static ground doesn't need events
let ground = physics.new_rigid_body(physics.Fixed)
  |> physics.with_collider(physics.Box(
    offset: transform.identity,
    width: 50.0,
    height: 1.0,
    depth: 50.0,
  ))
  |> physics.build()  // No events, saves performance
pub fn with_collision_groups(
  builder: RigidBodyBuilder(a),
  membership membership: List(Int),
  can_collide_with filter: List(Int),
) -> RigidBodyBuilder(b)

Set collision groups for filtering which objects can collide

Example

// Player belongs to layer 0, collides with enemies (1) and ground (2)
let body = physics.new_rigid_body(physics.Dynamic)
  |> physics.body_collider(physics.Capsule(1.0, 0.5))
  |> physics.body_collision_groups(
    membership: [0],
    filter: [1, 2]
  )
  |> physics.build_body()
pub fn with_friction(
  builder: RigidBodyBuilder(a),
  friction: Float,
) -> RigidBodyBuilder(b)

Set friction coefficient.

Friction: 0.0 = ice (no friction), 1.0+ = very grippy Default: 0.5

Example

// Slippery ice
physics.new_rigid_body(physics.Fixed)
  |> physics.with_friction(0.05)

// Grippy rubber
physics.new_rigid_body(physics.Fixed)
  |> physics.with_friction(0.9)
pub fn with_linear_damping(
  builder: RigidBodyBuilder(a),
  damping: Float,
) -> RigidBodyBuilder(b)

Set linear damping (air resistance for translation).

Damping: 0.0 = no resistance, higher = more drag Useful for simulating air/water resistance. Default: 0.0

Example

// Underwater physics
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_linear_damping(2.0)  // Heavy water resistance
pub fn with_lock_rotation_x(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Lock rotation on the X axis (pitch)

pub fn with_lock_rotation_y(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Lock rotation on the Y axis (yaw)

pub fn with_lock_rotation_z(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Lock rotation on the Z axis (roll)

pub fn with_lock_translation_x(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Lock translation on the X axis

pub fn with_lock_translation_y(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Lock translation on the Y axis

pub fn with_lock_translation_z(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Lock translation on the Z axis

pub fn with_mass(
  builder: RigidBodyBuilder(a),
  mass: Float,
) -> RigidBodyBuilder(b)

Set the mass in kilograms (for Dynamic bodies).

Mass affects how forces and collisions influence the body. Default: Calculated from volume and density if not specified.

Example

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_mass(70.0)  // Average human = 70kg
pub fn with_restitution(
  builder: RigidBodyBuilder(a),
  restitution: Float,
) -> RigidBodyBuilder(b)

Set restitution (bounciness).

Restitution: 0.0 = no bounce, 1.0 = perfect bounce (energy conserved) Default: 0.3

Example

// Bouncy ball
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_restitution(0.9)  // Very bouncy

// Non-bouncy box
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_restitution(0.1)  // Barely bounces
pub fn with_sensor(
  builder: RigidBodyBuilder(a),
) -> RigidBodyBuilder(b)

Make this collider a sensor (trigger).

Sensors detect collisions and generate events, but don’t cause physical response (no pushing, bouncing, or blocking). Perfect for:

  • Projectiles that should pass through targets
  • Trigger zones (checkpoints, damage areas)
  • Collectibles that don’t block movement

Note: Sensor colliders still need with_collision_events() to receive collision events.

Example

// Projectile that detects hits but passes through enemies
let bullet = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Sphere(transform.identity, 0.1))
  |> physics.with_sensor()
  |> physics.with_collision_events()
  |> physics.build()
Search Document