Physics

Adding physics to your game transforms static scenes into dynamic worlds. Objects fall, bounce, collide, and respond to forces. Tiramisu integrates Rapier, a high-performance physics engine, while maintaining the declarative style you’ve learned.

This guide walks you through physics from the ground up: creating a world, adding bodies, detecting collisions, and building game mechanics on top.

Why Rapier?

Rapier is a modern physics engine written in Rust and compiled to WebAssembly. It offers:

Tiramisu wraps Rapier in a declarative API that fits the MVU architecture. You describe physics bodies alongside your scene nodes, and the engine handles simulation.

The physics flow

Physics in Tiramisu follows a specific pattern:

+-------------------------------------------------------------------+
|                                                                   |
|   init() ---> Create physics world ---> Return Some(world)        |
|                                                                   |
|   update(Tick) ---> Step simulation ---> Return Some(new_world)   |
|                                                                   |
|   view() ---> Query transforms from world ---> Render scene       |
|                                                                   |
+-------------------------------------------------------------------+

The physics world flows through your functions:

  1. init creates the world with gravity and returns it
  2. update steps the simulation each frame and returns the updated world
  3. view queries body positions to render meshes at the right locations

Your first physics scene

Let’s create a ball bouncing on a floor:

import gleam/option.{None, Some}
import tiramisu
import tiramisu/camera
import tiramisu/effect
import tiramisu/geometry
import tiramisu/material
import tiramisu/physics
import tiramisu/scene
import tiramisu/transform
import vec/vec2
import vec/vec3

pub type Model {
  Model
}

pub type Msg {
  Tick
}

pub fn main() {
  let assert Ok(_) =
    tiramisu.application(init, update, view)
    |> tiramisu.start("#app", tiramisu.FullScreen, None)
  Nil
}

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

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

fn update(model: Model, msg: Msg, ctx: tiramisu.Context) {
  case msg {
    Tick -> {
      let assert Some(world) = ctx.physics_world

      // Step simulation forward
      let new_world = physics.step(world, ctx.delta_time)

      #(model, effect.dispatch(Tick), Some(new_world))
    }
  }
}

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

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

  let assert Ok(ground_geo) = geometry.box(size: vec3.Vec3(20.0, 0.5, 20.0))
  let assert Ok(ground_mat) =
    material.new()
    |> material.with_color(0x808080)
    |> material.build()

  let assert Ok(ball_geo) = geometry.sphere(radius: 1.0, segments: vec2.Vec2(32, 16))
  let assert Ok(ball_mat) =
    material.new()
    |> material.with_color(0xff4444)
    |> material.build()

  // Get ball position from physics simulation
  let ball_transform = case physics.get_transform(world, "ball") {
    Ok(t) -> t
    Error(Nil) -> transform.at(position: vec3.Vec3(0.0, 10.0, 0.0))
  }

  scene.empty(id: "root", transform: transform.identity, children: [])
  |> scene.with_children([
    scene.camera(
      id: "camera",
      camera: cam,
      transform: transform.at(position: vec3.Vec3(0.0, 5.0, 15.0)),
      look_at: Some(vec3.Vec3(0.0, 0.0, 0.0)),
      active: True,
      viewport: None,
      postprocessing: None,
    ),

    // Static ground
    scene.mesh(
      id: "ground",
      geometry: ground_geo,
      material: ground_mat,
      transform: transform.identity,
      physics: Some(
        physics.new_rigid_body(physics.Fixed)
        |> physics.with_collider(physics.Box(
          offset: transform.identity,
          size: vec3.Vec3(20.0, 0.5, 20.0),
        ))
        |> physics.build()
      ),
    ),

    // Bouncing ball
    scene.mesh(
      id: "ball",
      geometry: ball_geo,
      material: ball_mat,
      transform: ball_transform,
      physics: Some(
        physics.new_rigid_body(physics.Dynamic)
        |> physics.with_collider(physics.Sphere(
          offset: transform.identity,
          radius: 1.0,
        ))
        |> physics.with_restitution(0.8)
        |> physics.build()
      ),
    ),
  ])
}

Run this and you’ll see a red ball drop and bounce on the gray floor. Let’s break down what’s happening.

Creating the physics world

The physics world contains all bodies and handles simulation:

let world = physics.new_world(
  physics.WorldConfig(gravity: vec3.Vec3(0.0, -9.81, 0.0))
)

Gravity is a vector in units per second squared. Earth gravity is approximately -9.81 m/s² on the Y axis. You can change this for different effects:

// Moon gravity (1/6 of Earth)
vec3.Vec3(0.0, -1.62, 0.0)

// No gravity (space)
vec3.Vec3(0.0, 0.0, 0.0)

// Sideways gravity (weird puzzle game?)
vec3.Vec3(-5.0, 0.0, 0.0)

Stepping the simulation

Each frame, call physics.step to advance the simulation:

let new_world = physics.step(world, ctx.delta_time)

The step function:

The delta_time parameter (a Duration type) ensures physics runs at the same speed regardless of frame rate. Internally, Rapier uses a fixed timestep with an accumulator for deterministic simulation.

Rigid body types

Every physics object is a rigid body. Tiramisu supports three types:

Dynamic

Affected by gravity and forces. Moves when pushed or hit:

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(...)
  |> physics.with_mass(5.0)
  |> physics.build()

Use for: Player characters, enemies, projectiles, physics props

Fixed

Never moves. Other bodies collide with it but can’t push it:

physics.new_rigid_body(physics.Fixed)
  |> physics.with_collider(...)
  |> physics.build()

Use for: Ground, walls, platforms, level geometry

Kinematic

Moved by code, not physics. Pushes dynamic bodies but isn’t affected by them:

physics.new_rigid_body(physics.Kinematic)
  |> physics.with_collider(...)
  |> physics.build()

Use for: Moving platforms, doors, elevators, scripted objects

Collider shapes

Colliders define the shape used for collision detection. Choose shapes that approximate your geometry:

Box

physics.Box(
  offset: transform.identity,
  size: vec3.Vec3(2.0, 1.0, 3.0),  // Full dimensions
)

Use for: Crates, buildings, rectangular objects

Sphere

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

Use for: Balls, planets, character approximations

Capsule

A cylinder with hemispherical caps. The most popular choice for characters:

physics.Capsule(
  offset: transform.identity,
  half_height: 0.9,  // Half the cylinder height
  radius: 0.4,       // Radius of cylinder and caps
)

Use for: Characters, humanoid shapes

Cylinder

physics.Cylinder(
  offset: transform.identity,
  half_height: 1.0,
  radius: 0.5,
)

Use for: Barrels, columns, tree trunks

Offsets

You can offset a collider from the body’s origin:

physics.Box(
  offset: transform.at(position: vec3.Vec3(0.0, 1.0, 0.0)),
  size: vec3.Vec3(1.0, 1.0, 1.0),
)

This places the collider 1 unit above the body’s center. Useful when the visual mesh and collision shape need different centers.

Physics properties

The builder pattern lets you configure body behavior:

Mass

How heavy the object is. Affects how forces accelerate it:

physics.with_mass(10.0)  // 10 kg

Heavier objects are harder to push and have more momentum.

Restitution (bounciness)

How much energy is preserved on collision:

physics.with_restitution(0.8)  // Very bouncy
physics.with_restitution(0.0)  // No bounce at all

Values range from 0 (no bounce) to 1 (perfect bounce). Values above 1 add energy on each bounce.

Friction

Resistance when surfaces slide:

physics.with_friction(0.5)  // Normal friction
physics.with_friction(0.0)  // Ice-like
physics.with_friction(1.0)  // Sticky

Damping

Reduces velocity over time (air resistance):

physics.with_linear_damping(0.1)   // Slows movement
physics.with_angular_damping(0.1)  // Slows rotation

Applying forces

You can push objects around in your update function:

Forces (continuous)

Apply over time, like a thruster:

let world = physics.apply_force(
  world,
  "player",
  vec3.Vec3(100.0, 0.0, 0.0),  // Push right with 100 Newtons
)

Impulses (instant)

Apply instantly, like a jump or explosion:

let world = physics.apply_impulse(
  world,
  "player",
  vec3.Vec3(0.0, 300.0, 0.0),  // Instant upward velocity
)

Set velocity directly

For precise control:

let world = physics.set_velocity(
  world,
  "player",
  vec3.Vec3(5.0, 0.0, 0.0),  // Move at exactly 5 m/s right
)

Example: WASD movement

fn update(model: Model, msg: Msg, ctx: tiramisu.Context) {
  case msg {
    Tick -> {
      let assert Some(world) = ctx.physics_world

      let move_speed = 500.0

      // Apply forces based on input
      let world = case input.is_key_pressed(ctx.input, input.KeyW) {
        True -> physics.apply_force(world, "player", vec3.Vec3(0.0, 0.0, -move_speed))
        False -> world
      }

      let world = case input.is_key_pressed(ctx.input, input.KeyS) {
        True -> physics.apply_force(world, "player", vec3.Vec3(0.0, 0.0, move_speed))
        False -> world
      }

      let world = case input.is_key_pressed(ctx.input, input.KeyA) {
        True -> physics.apply_force(world, "player", vec3.Vec3(-move_speed, 0.0, 0.0))
        False -> world
      }

      let world = case input.is_key_pressed(ctx.input, input.KeyD) {
        True -> physics.apply_force(world, "player", vec3.Vec3(move_speed, 0.0, 0.0))
        False -> world
      }

      // Jump on space (just pressed, not held)
      let world = case input.is_key_just_pressed(ctx.input, input.KeySpace) {
        True -> physics.apply_impulse(world, "player", vec3.Vec3(0.0, 300.0, 0.0))
        False -> world
      }

      let new_world = physics.step(world, ctx.delta_time)

      #(model, effect.dispatch(Tick), Some(new_world))
    }
  }
}

Collision detection

Rapier detects when bodies touch. You can query these events after stepping:

Enabling collision events

First, mark bodies that should generate events:

physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(physics.Sphere(offset: transform.identity, radius: 0.5))
  |> physics.with_collision_events()  // Enable event generation
  |> physics.build()

Querying events

After stepping, get the collision events:

fn update(model: Model, msg: Msg, ctx: tiramisu.Context) {
  case msg {
    Tick -> {
      let assert Some(world) = ctx.physics_world
      let new_world = physics.step(world, ctx.delta_time)

      // Get collision events from this step
      let events = physics.get_collision_events(new_world)

      // Process them
      let new_model = list.fold(events, model, fn(acc, event) {
        case event {
          physics.CollisionStarted(body_a, body_b) -> {
            // Two bodies just started touching
            handle_collision_start(acc, body_a, body_b)
          }
          physics.CollisionEnded(body_a, body_b) -> {
            // Two bodies stopped touching
            handle_collision_end(acc, body_a, body_b)
          }
        }
      })

      #(new_model, effect.dispatch(Tick), Some(new_world))
    }
  }
}

fn handle_collision_start(model: Model, body_a: String, body_b: String) -> Model {
  // Check if player hit a coin
  case body_a == "player" && string.starts_with(body_b, "coin-") {
    True -> Model(..model, score: model.score + 1)
    False -> model
  }
}

Collision groups

Not everything should collide with everything. Use collision groups to control this:

// Player: layer 0, collides with enemies (1), ground (2), pickups (3)
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(...)
  |> physics.with_collision_groups(
    membership: [0],
    can_collide_with: [1, 2, 3],
  )
  |> physics.build()

// Enemy: layer 1, collides with player (0) and ground (2), but not other enemies
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_collider(...)
  |> physics.with_collision_groups(
    membership: [1],
    can_collide_with: [0, 2],
  )
  |> physics.build()

// Ground: layer 2, collides with everything
physics.new_rigid_body(physics.Fixed)
  |> physics.with_collider(...)
  |> physics.with_collision_groups(
    membership: [2],
    can_collide_with: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
  )
  |> physics.build()

Layers 0-15 are available. Plan your layer usage:

Axis locks

Restrict movement or rotation on specific axes:

// Lock all rotation (useful for top-down or platformer characters)
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_lock_rotation_x()
  |> physics.with_lock_rotation_y()
  |> physics.with_lock_rotation_z()
  |> physics.build()

// Lock vertical movement (top-down game)
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_lock_translation_y()
  |> physics.build()

// Lock depth movement (2D side-scroller)
physics.new_rigid_body(physics.Dynamic)
  |> physics.with_lock_translation_z()
  |> physics.build()

Raycasting

Cast a ray and find what it hits:

let result = physics.raycast(
  world,
  origin: vec3.Vec3(0.0, 10.0, 0.0),
  direction: vec3.Vec3(0.0, -1.0, 0.0),  // Down
  max_distance: 100.0,
)

case result {
  Ok(hit) -> {
    // hit.id - which body was hit
    // hit.point - world position of hit
    // hit.normal - surface normal at hit point
    // hit.distance - distance from origin to hit
  }
  Error(Nil) -> {
    // Ray didn't hit anything
  }
}

Use for:

Character controllers

For precise character movement, use a kinematic body with a character controller:

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,  // Skin width for smooth sliding
    up_vector: vec3.Vec3(0.0, 1.0, 0.0),
    slide_enabled: True,
  )
  |> physics.build()

Then in update, compute safe movement:

let desired_movement = vec3.Vec3(move_x, 0.0, move_z)

case physics.compute_character_movement(world, "player", desired_movement) {
  Ok(safe_movement) -> {
    // safe_movement is adjusted to not penetrate walls
    physics.set_kinematic_translation(world, "player", safe_movement)
  }
  Error(_) -> world
}

Check if grounded:

case physics.is_character_grounded(world, "player") {
  Ok(True) -> // Can jump
  Ok(False) -> // In the air
  Error(_) -> // No character controller
}

Common patterns

Player with health

pub type Model {
  Model(health: Float)
}

fn update(model: Model, msg: Msg, ctx: tiramisu.Context) {
  case msg {
    Tick -> {
      let assert Some(world) = ctx.physics_world
      let new_world = physics.step(world, ctx.delta_time)

      let events = physics.get_collision_events(new_world)

      let new_health = list.fold(events, model.health, fn(hp, event) {
        case event {
          physics.CollisionStarted(a, b) -> {
            case a == "player" && string.starts_with(b, "enemy-"),
                 b == "player" && string.starts_with(a, "enemy-") {
              True, _ | _, True -> hp -. 10.0
              _, _ -> hp
            }
          }
          _ -> hp
        }
      })

      #(Model(health: new_health), effect.dispatch(Tick), Some(new_world))
    }
  }
}

Projectiles

fn spawn_bullet(world: physics.PhysicsWorld, position: Vec3, direction: Vec3) {
  // Bullets are dynamic but very fast
  // Use high velocity instead of forces for instant travel
  let bullet_body =
    physics.new_rigid_body(physics.Dynamic)
    |> physics.with_collider(physics.Sphere(offset: transform.identity, radius: 0.1))
    |> physics.with_collision_events()
    |> physics.with_collision_groups(membership: [4], can_collide_with: [1, 2])  // Hit enemies and walls
    |> physics.build()

  // Set initial velocity
  physics.set_velocity(world, "bullet-" <> new_id(), vec3.scale(direction, 50.0))
}

Triggers (no physics response)

For areas that detect entry but don’t block movement:

// Make a trigger by using collision events without physical response
scene.mesh(
  id: "checkpoint",
  geometry: checkpoint_geo,
  material: transparent_material,
  transform: checkpoint_transform,
  physics: Some(
    physics.new_rigid_body(physics.Fixed)
    |> physics.with_collider(physics.Box(offset: transform.identity, size: vec3.Vec3(2.0, 4.0, 2.0)))
    |> physics.with_collision_events()
    |> physics.with_sensor()  // Detects but doesn't block
    |> physics.build()
  ),
)

Performance tips

Use Fixed bodies for static geometry

Fixed bodies are highly optimized. Use them for anything that doesn’t move.

Limit collision events

Only enable with_collision_events() on bodies that need it. Every collision event has overhead.

Use collision groups

Reducing the number of potential collisions is faster than resolving them. If enemies don’t need to collide with each other, put them in the same layer and exclude self-collision.

Next steps

You now understand how to add physics to your Tiramisu games. The key insights:

  1. Physics world flows through init -> update -> view
  2. Three body types: Dynamic, Fixed, Kinematic
  3. Collider shapes approximate your geometry
  4. Forces are continuous, impulses are instant
  5. Collision events drive game logic
  6. Character controllers give precise movement

Next, learn about Lustre Integration to add UI overlays to your game.

Search Document