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
-
DynamicDynamic bodies are affected by forces and gravity
-
KinematicKinematic bodies can be moved programmatically but don’t respond to forces
-
FixedFixed (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
-
Box(offset: transform.Transform, size: vec3.Vec3(Float))Box collider with size (width, height, depth)
-
Sphere(offset: transform.Transform, radius: Float)Sphere collider with radius
-
Capsule( offset: transform.Transform, half_height: Float, radius: Float, )Capsule collider (cylinder with rounded caps)
-
Cylinder( offset: transform.Transform, half_height: Float, radius: Float, )Cylinder collider
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
-
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
Builder for creating rigid bodies with a fluent API
pub opaque type RigidBodyBuilder(a)
pub type WithCollider
pub type WithoutCollider
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()