Physics Guide

Tiramisu integrates the Rapier physics engine to provide realistic physics simulation for your games. This guide covers everything from basic setup to advanced collision handling.

Overview

What is Rapier?

Rapier is a fast, cross-platform physics engine with:

Tiramisu’s Approach

Physics in Tiramisu follows the same declarative, immutable pattern as the rest of the engine:

  1. Declare physics bodies alongside your scene nodes
  2. Initialize physics world in your init() function
  3. Step simulation in your update() function
  4. Query transforms in your view() function

Quick Start

Basic Physics Scene

import gleam/option
import tiramisu
import tiramisu/background
import tiramisu/camera
import tiramisu/effect
import tiramisu/geometry
import tiramisu/light
import tiramisu/material
import tiramisu/physics
import tiramisu/scene
import tiramisu/transform
import vec/vec3

pub type Id {
  Ground
  Ball
}

pub type Model {
  Model
}

pub type Msg {
  Tick
}

pub fn main() {
  tiramisu.run(
    dimensions: option.None,
    background: background.Color(0x1a1a2e),
    init: init,
    update: update,
    view: view,
  )
}

fn init(_ctx: tiramisu.Context(Id)) -> #(Model, effect.Effect(Msg), option.Option(_)) {
  // Create physics world with Earth gravity
  let physics_world = physics.new_world(
    physics.WorldConfig(gravity: vec3.Vec3(0.0, -9.81, 0.0))
  )

  #(Model, effect.tick(Tick), option.Some(physics_world))
}

fn update(
  model: Model,
  msg: Msg,
  ctx: tiramisu.Context(Id),
) -> #(Model, effect.Effect(Msg), option.Option(_)) {
  let assert option.Some(physics_world) = ctx.physics_world

  case msg {
    Tick -> {
      // Step the physics simulation
      let new_physics_world = physics.step(physics_world)
      #(model, effect.tick(Tick), option.Some(new_physics_world))
    }
  }
}

fn view(model: Model, ctx: tiramisu.Context(Id)) -> List(scene.Node(Id)) {
  let assert option.Some(physics_world) = ctx.physics_world

  let assert Ok(cam) = camera.perspective(field_of_view: 75.0, near: 0.1, far: 1000.0)

  [
    // Camera
    scene.Camera(
      id: "camera",
      camera: cam,
      transform: transform.at(position: vec3.Vec3(0.0, 5.0, 10.0)),
      look_at: option.Some(vec3.Vec3(0.0, 0.0, 0.0)),
      active: True,
      viewport: option.None,
    ),

    // Static ground
    scene.Mesh(
      id: Ground,
      geometry: {
        let assert Ok(geo) = geometry.box(width: 20.0, height: 0.5, depth: 20.0)
        geo
      },
      material: {
        let assert Ok(mat) = material.new()
          |> material.with_color(0x808080)
          |> material.build()
        mat
      },
      transform: transform.identity,
      physics: option.Some(
        physics.new_rigid_body(physics.Fixed)
        |> physics.with_collider(physics.Box(transform.identity, 20.0, 0.5, 20.0))
        |> physics.build()
      ),
    ),

    // Bouncing ball
    scene.Mesh(
      id: Ball,
      geometry: {
        let assert Ok(geo) = geometry.sphere(radius: 1.0, width_segments: 32, height_segments: 16)
        geo
      },
      material: {
        let assert Ok(mat) = material.new()
          |> material.with_color(0xff4444)
          |> material.build()
        mat
      },
      // Get transform from physics simulation, or use initial position
      transform: case physics.get_transform(physics_world, Ball) {
        Ok(t) -> t
        Error(Nil) -> transform.at(position: vec3.Vec3(0.0, 10.0, 0.0))
      },
      physics: option.Some(
        physics.new_rigid_body(physics.Dynamic)
        |> physics.with_collider(physics.Sphere(transform.identity, 1.0))
        |> physics.with_mass(1.0)
        |> physics.with_restitution(0.8)  // Very bouncy!
        |> physics.build()
      ),
    ),
  ]
}

Physics World

Creating a World

The physics world is initialized in your init() function and returned as the third element of the tuple:

fn init(_ctx: tiramisu.Context(Id)) -> #(Model, effect.Effect(Msg), option.Option(_)) {
  let physics_world = physics.new_world(
    physics.WorldConfig(
      gravity: vec3.Vec3(0.0, -9.81, 0.0)  // Earth gravity (m/s²)
    )
  )

  #(Model(...), effect.tick(Tick), option.Some(physics_world))
}

Common gravity values:

Stepping the Simulation

Call physics.step() every frame in your update() function:

fn update(
  model: Model,
  msg: Msg,
  ctx: tiramisu.Context(Id),
) -> #(Model, effect.Effect(Msg), option.Option(_)) {
  let assert option.Some(physics_world) = ctx.physics_world

  case msg {
    Tick -> {
      let new_physics_world = physics.step(physics_world)
      #(model, effect.tick(Tick), option.Some(new_physics_world))
    }
  }
}

The step function:

Accessing the World in View

fn view(model: Model, ctx: tiramisu.Context(Id)) -> List(scene.Node(Id)) {
  let assert option.Some(physics_world) = ctx.physics_world

  // Get physics transforms for dynamic bodies
  let player_transform = case physics.get_transform(physics_world, PlayerId) {
    Ok(t) -> t
    Error(Nil) -> transform.identity  // Fallback for first frame
  }

  // ... rest of scene
}

Rigid Bodies

Body Types

Dynamic - Affected by forces, gravity, and collisions:

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_mass(5.0)
  |> physics.with_collider(physics.Box(transform.identity, 1.0, 1.0, 1.0))
  |> physics.build()

Use for: Moveable objects, projectiles, character physics

Kinematic - Moved programmatically, affects dynamic bodies but not affected by forces:

physics.new_rigid_body(physics.Kinematic)
  |> physics.with_collider(physics.Box(transform.identity, 5.0, 5.0, 5.0))
  |> physics.build()

Use for: Moving platforms, doors, elevators

Fixed - Static, immovable objects:

physics.new_rigid_body(physics.Fixed)
  |> physics.with_collider(physics.Box(transform.identity, 100.0, 1.0, 100.0))
  |> physics.build()

Use for: Terrain, walls, floors, static obstacles

Builder Pattern

All physics bodies use the builder pattern:

let body = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(collider_shape)
  |> physics.with_mass(10.0)
  |> physics.with_restitution(0.5)
  |> physics.with_friction(0.8)
  |> physics.with_linear_damping(0.1)
  |> physics.with_angular_damping(0.1)
  |> physics.with_collision_groups(groups)
  |> physics.with_axis_locks(locks)
  |> physics.build()

Collider Shapes

Box Collider

Box-shaped collision volume:

physics.Box(
  offset: transform.identity,  // Offset from body center
  width: 2.0,                   // Full width (not half-extents)
  height: 1.0,                  // Full height
  depth: 2.0,                   // Full depth
)

Use for: Crates, buildings, walls, platforms

Sphere Collider

Spherical collision volume:

physics.Sphere(
  offset: transform.identity,
  radius: 1.0,
)

Use for: Balls, planets, round objects, character approximation

Capsule Collider

Cylinder with hemispherical caps (best for characters):

physics.Capsule(
  offset: transform.identity,
  half_height: 1.0,  // Half-height of cylindrical section
  radius: 0.5,       // Radius of cylinder and caps
)

Use for: Characters, pills, rounded objects

Cylinder Collider

Cylindrical collision volume:

physics.Cylinder(
  offset: transform.identity,
  half_height: 2.0,  // Half of total height
  radius: 1.0,       // Radius of cylinder
)

Use for: Barrels, columns, trees

Collider Offset

All colliders can be offset from the body’s center:

// Collider at body center
physics.Box(transform.identity, 1.0, 1.0, 1.0)

// Collider offset 0.5 units up
physics.Box(
  transform.at(position: vec3.Vec3(0.0, 0.5, 0.0)),
  1.0, 1.0, 1.0
)

Physics Properties

Mass

Mass affects how forces influence dynamic bodies. Higher mass = harder to move.

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_mass(5.0)  // 5 kg
  |> physics.build()

Guidelines:

Restitution (Bounciness)

Controls how much energy is preserved when bouncing:

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_restitution(0.8)  // 0.0 = no bounce, 1.0 = perfect bounce
  |> physics.build()

Common values:

Friction

Resistance when surfaces slide against each other:

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_friction(0.5)  // 0.0 = ice, 1.0+ = very sticky
  |> physics.build()

Common values:

Damping

Reduces velocity over time (simulates air resistance):

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_linear_damping(0.1)   // Slows down movement
  |> physics.with_angular_damping(0.1)  // Slows down rotation
  |> physics.build()

Guidelines:

Forces and Motion

Applying Forces

Forces are applied over time (acceleration):

// Queue a force to be applied on next physics step
let physics_world = physics.apply_force(
  physics_world,
  body_id: PlayerId,
  force: vec3.Vec3(100.0, 0.0, 0.0),  // Push 100 N to the right
)

Use for: Continuous acceleration, wind, thrusters

Applying Impulses

Impulses are instant velocity changes:

// Instant velocity change (like a jump or explosion)
let physics_world = physics.apply_impulse(
  physics_world,
  body_id: PlayerId,
  impulse: vec3.Vec3(0.0, 500.0, 0.0),  // Instant upward velocity
)

Use for: Jumps, explosions, instant hits

Setting Velocity Directly

// Set exact velocity
let physics_world = physics.set_velocity(
  physics_world,
  body_id: PlayerId,
  velocity: vec3.Vec3(5.0, 0.0, 0.0),  // Move at 5 m/s right
)

Use for: Character controllers, vehicles, special movement

Angular Forces and Torques

// Apply rotational force
let physics_world = physics.apply_torque(
  physics_world,
  body_id: ObjectId,
  torque: vec3.Vec3(0.0, 10.0, 0.0),  // Rotate around Y axis
)

// Apply instant rotational impulse
let physics_world = physics.apply_torque_impulse(
  physics_world,
  body_id: ObjectId,
  impulse: vec3.Vec3(0.0, 50.0, 0.0),
)

// Set angular velocity directly
let physics_world = physics.set_angular_velocity(
  physics_world,
  body_id: ObjectId,
  velocity: vec3.Vec3(0.0, 3.14, 0.0),  // Rotate 180°/second around Y
)

Example: Character Controller

fn handle_player_input(
  physics_world: physics.PhysicsWorld(Id),
  input: input.InputState,
) -> physics.PhysicsWorld(Id) {
  let move_force = 500.0
  let jump_impulse = 300.0

  let physics_world = case input.is_key_pressed(input, input.KeyW) {
    True -> physics.apply_force(physics_world, PlayerId, vec3.Vec3(0.0, 0.0, -move_force))
    False -> physics_world
  }

  let physics_world = case input.is_key_pressed(input, input.KeyS) {
    True -> physics.apply_force(physics_world, PlayerId, vec3.Vec3(0.0, 0.0, move_force))
    False -> physics_world
  }

  let physics_world = case input.is_key_just_pressed(input, input.KeySpace) {
    True -> physics.apply_impulse(physics_world, PlayerId, vec3.Vec3(0.0, jump_impulse, 0.0))
    False -> physics_world
  }

  physics_world
}

Collision Detection

Collision Events

Get collision events from the physics world after stepping:

fn update(
  model: Model,
  msg: Msg,
  ctx: tiramisu.Context(Id),
) -> #(Model, effect.Effect(Msg), option.Option(_)) {
  let assert option.Some(physics_world) = ctx.physics_world

  case msg {
    Tick -> {
      let new_physics_world = physics.step(physics_world)

      // Check for collisions
      let events = physics.get_collision_events(new_physics_world)

      // Process collision events
      let model = list.fold(events, model, fn(acc_model, event) {
        case event {
          physics.CollisionStarted(body1, body2) -> {
            // Handle collision started
            io.println("Collision between " <> debug.inspect(body1) <> " and " <> debug.inspect(body2))
            acc_model
          }
          physics.CollisionEnded(body1, body2) -> {
            // Handle collision ended
            acc_model
          }
        }
      })

      #(model, effect.tick(Tick), option.Some(new_physics_world))
    }
  }
}

Collision Groups

Control which objects can collide using collision groups:

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

let player_body = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Capsule(transform.identity, 1.0, 0.5))
  |> physics.with_collision_groups(player_groups)
  |> physics.build()

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

let enemy_body = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Box(transform.identity, 1.0, 1.0, 1.0))
  |> physics.with_collision_groups(enemy_groups)
  |> physics.build()

// 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],
)

Layer system:

Example: Layer-Based Damage System

pub type Id {
  Player
  Enemy(Int)
  Projectile(Int)
  Ground
}

fn setup_collision_layers() {
  let player_groups = physics.CollisionGroups(
    membership: [0],           // Player layer
    filter: [1, 2, 4, 5],     // Collides with enemies, ground, triggers, pickups
  )

  let enemy_groups = physics.CollisionGroups(
    membership: [1],           // Enemy layer
    filter: [0, 2, 3],        // Collides with player, ground, projectiles
  )

  let projectile_groups = physics.CollisionGroups(
    membership: [3],           // Projectile layer
    filter: [1, 2],           // Only collides with enemies and ground
  )

  let ground_groups = physics.CollisionGroups(
    membership: [2],           // Ground layer
    filter: [0, 1, 3],        // Collides with player, enemies, projectiles
  )

  // Apply to bodies...
}

fn process_collisions(events: List(physics.CollisionEvent), model: Model) -> Model {
  list.fold(events, model, fn(acc_model, event) {
    case event {
      physics.CollisionStarted(Projectile(id), Enemy(enemy_id)) -> {
        // Projectile hit enemy - apply damage
        damage_enemy(acc_model, enemy_id, 10)
      }
      physics.CollisionStarted(Player, Enemy(_)) -> {
        // Player touched enemy - take damage
        damage_player(acc_model, 5)
      }
      _ -> acc_model
    }
  })
}

Axis Locks

Restrict movement and rotation on specific axes:

// Lock all rotation (useful for top-down games)
let locks = physics.AxisLock(
  lock_translation_x: False,
  lock_translation_y: False,
  lock_translation_z: False,
  lock_rotation_x: True,   // Can't pitch
  lock_rotation_y: True,   // Can't yaw
  lock_rotation_z: True,   // Can't roll
)

let body = physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Capsule(transform.identity, 1.0, 0.5))
  |> physics.with_axis_locks(locks)
  |> physics.build()

Common patterns:

Top-down game (2D movement, no rotation):

physics.AxisLock(
  lock_translation_x: False,
  lock_translation_y: True,   // Lock vertical movement
  lock_translation_z: False,
  lock_rotation_x: True,      // Lock all rotation
  lock_rotation_y: True,
  lock_rotation_z: True,
)

Platformer (2D side-scroller):

physics.AxisLock(
  lock_translation_x: False,
  lock_translation_y: False,
  lock_translation_z: True,   // Lock depth
  lock_rotation_x: True,      // Lock all rotation
  lock_rotation_y: True,
  lock_rotation_z: True,
)

Standing character (upright, can rotate on Y only):

physics.AxisLock(
  lock_translation_x: False,
  lock_translation_y: False,
  lock_translation_z: False,
  lock_rotation_x: True,      // Can't tip over
  lock_rotation_y: False,     // Can turn left/right
  lock_rotation_z: True,      // Can't tip over
)

Physics Queries

Raycasting

Cast a ray and find the first object hit:

let result = physics.raycast(
  physics_world,
  origin: vec3.Vec3(0.0, 10.0, 0.0),
  direction: vec3.Vec3(0.0, -1.0, 0.0),  // Shoot downward
  max_distance: 100.0,
  solid: True,  // Stop at first hit
)

case result {
  option.Some(#(body_id, hit_point, hit_normal, distance)) -> {
    io.println("Hit " <> debug.inspect(body_id) <> " at distance " <> float.to_string(distance))
  }
  option.None -> {
    io.println("No hit")
  }
}

Use for: Ground detection, shooting, line of sight, mouse picking

Debug Visualization

Enable collider wireframes for debugging:

import tiramisu/debug

fn view(model: Model, ctx: tiramisu.Context(Id)) -> List(scene.Node(Id)) {
  let assert option.Some(physics_world) = ctx.physics_world

  // Show/hide debug wireframes with a key press
  case model.debug_mode {
    True -> debug.show_collider_wireframes(physics_world, True)
    False -> debug.show_collider_wireframes(physics_world, False)
  }

  // ... rest of scene
}

Or manually visualize specific colliders:

import tiramisu/debug

// Visualize a collider shape
let collider_shape = physics.Box(transform.identity, 2.0, 1.0, 2.0)

let debug_vis = debug.collider(
  id: "player-collider-debug",
  shape: collider_shape,
  transform: player_transform,
  color: debug.color_green,
)

Summary

Key concepts:

Performance tips:

Next steps:

Search Document