When you need gravity without the math
You are building a 2D platformer. You want a character to jump, fall, and land on platforms. You also want crates to tumble realistically when pushed. Writing the collision detection, impulse resolution, and constraint solvers from scratch is a rabbit hole that takes months. You need a physics engine. In Rust, the modern standard is Rapier. It is fast, written in pure Rust, and designed to fit cleanly into the borrow checker.
How a physics engine actually works
A physics engine is just a state machine that runs in a loop. You hand it a collection of objects, tell it how much time has passed, and it calculates where everything should be next. Rapier breaks this into three main pieces. Rigid bodies represent the objects that move. They carry mass, velocity, and position. Colliders are the invisible shapes attached to those bodies. They define the boundaries for collision detection. The pipeline is the conductor. It runs the broad phase to find potential collisions, the narrow phase to calculate exact contact points, and the integration step to apply forces and update positions.
Think of it like a movie projector. You load the film reel with your objects and rules. Every time you advance the reel by one frame, the projector calculates the next state. You just need to feed it consistent time steps and collect the results. Treat the pipeline as a black box that trades your time delta for updated positions.
The minimal setup
Start by adding the crate. Rapier splits into 2D and 3D versions. Pick the one that matches your project.
[dependencies]
rapier2d = "0.23"
The engine requires three core collections to run. You need a set for rigid bodies, a set for colliders, and a pipeline to orchestrate the simulation. Here is the smallest working loop that drops a box onto the ground.
use rapier2d::dynamics::{RigidBodyBuilder, RigidBodySet};
use rapier2d::geometry::{ColliderBuilder, ColliderSet, NarrowPhase};
use rapier2d::pipeline::{PhysicsPipeline, QueryPipeline};
use rapier2d::na::Vector2;
fn main() {
// Initialize the core simulation structures
// The pipeline coordinates the simulation steps
let mut pipeline = PhysicsPipeline::new();
// Bodies store mass, velocity, and transform data
let mut bodies = RigidBodySet::new();
// Colliders store the geometric shapes for collision detection
let mut colliders = ColliderSet::new();
// Narrow phase caches contact points for performance
let mut narrow_phase = NarrowPhase::new();
// Query pipeline accelerates raycasts and shape intersections
let mut query_pipeline = QueryPipeline::new();
// Create a static ground plane at y = 0
// Infinite width ensures the box cannot fall off the edges
let ground = ColliderBuilder::cuboid(f32::INFINITY, 0.5).build();
colliders.insert(ground);
// Create a dynamic box starting at y = 5.0
// Dynamic bodies react to gravity and collisions
let box_body = RigidBodyBuilder::dynamic()
.position(Vector2::new(0.0, 5.0))
.build();
// Insert returns a lightweight handle for later lookups
let box_handle = bodies.insert(box_body);
// Attach a collision shape to the box
// The parent link ensures the collider follows the body
let box_collider = ColliderBuilder::cuboid(0.5, 0.5).build();
colliders.insert_with_parent(box_collider, box_handle, &mut bodies);
// Run a single simulation step with a fixed time delta
// Gravity pulls downward at standard Earth acceleration
let gravity = Vector2::new(0.0, -9.81);
pipeline.step(&gravity, &mut query_pipeline, &mut bodies, &mut colliders, &mut narrow_phase);
// The box is now resting on the ground
println!("Box position: {:?}", bodies.get(box_handle).unwrap().translation());
}
Notice the insert_with_parent call. Rapier expects colliders to be tied to a rigid body handle. The parent relationship tells the engine that the collider moves exactly with the body it belongs to. If you skip the parent link, the collider floats in world space and ignores the body's physics entirely. Always bind colliders to their parent bodies during setup.
Walking through the simulation step
When pipeline.step runs, Rapier does not just add velocity to position. It runs a full constraint solver. First, the broad phase uses spatial hashing to find which colliders are close enough to possibly touch. This avoids checking every object against every other object. Next, the narrow phase calculates exact contact manifolds. It finds the points of intersection and the surface normals.
The engine then builds a system of equations. It treats collisions and joints as constraints that must be satisfied. The solver iterates until the impulses needed to resolve overlaps and maintain contacts converge. Finally, the integration phase applies gravity, friction, and the resolved impulses to update velocities and positions.
This process is deterministic only if you feed it a fixed time step. Variable frame rates will cause the solver to produce different results depending on how fast your monitor refreshes. You must decouple your physics loop from your rendering loop. Feed the pipeline a constant delta, and your simulation will behave identically on every machine.
A realistic game loop
Real applications need a continuous loop that handles input, steps physics, and renders the result. You also need to handle the fixed timestep correctly. Here is how a production-ready loop looks.
use rapier2d::dynamics::{RigidBodyBuilder, RigidBodySet};
use rapier2d::geometry::{ColliderBuilder, ColliderSet, NarrowPhase};
use rapier2d::pipeline::{PhysicsPipeline, QueryPipeline};
use rapier2d::na::Vector2;
use std::time::Instant;
/// Runs a fixed-timestep physics loop for a simple simulation
fn run_simulation() {
let mut pipeline = PhysicsPipeline::new();
let mut bodies = RigidBodySet::new();
let mut colliders = ColliderSet::new();
let mut narrow_phase = NarrowPhase::new();
let mut query_pipeline = QueryPipeline::new();
// Set up initial scene
colliders.insert(ColliderBuilder::cuboid(f32::INFINITY, 0.5).build());
let handle = bodies.insert(RigidBodyBuilder::dynamic().position(Vector2::new(0.0, 5.0)).build());
colliders.insert_with_parent(ColliderBuilder::cuboid(0.5, 0.5).build(), handle, &mut bodies);
// Fixed timestep ensures deterministic constraint solving
let physics_step = std::time::Duration::from_millis(16);
let mut accumulator = std::time::Duration::ZERO;
let mut last_time = Instant::now();
let mut frame_count = 0;
// Run for 100 frames to demonstrate stability
while frame_count < 100 {
let now = Instant::now();
let delta = now.duration_since(last_time);
last_time = now;
accumulator += delta;
// Step physics multiple times if the frame took longer than one tick
// This prevents simulation drift during CPU spikes
while accumulator >= physics_step {
let gravity = Vector2::new(0.0, -9.81);
pipeline.step(&gravity, &mut query_pipeline, &mut bodies, &mut colliders, &mut narrow_phase);
accumulator -= physics_step;
}
frame_count += 1;
}
}
The accumulator pattern is the standard way to handle fixed timesteps. You collect real time in a buffer and drain it in exact chunks. This keeps the simulation stable even when the CPU hiccups or the GPU stalls. If you pass a wildly varying delta directly to pipeline.step, your objects will tunnel through walls or jitter violently. Keep the accumulator running. Let the physics step at its own pace.
Common pitfalls and how to avoid them
The borrow checker will stop you from making the worst mistakes, but physics engines have their own landmines. The most common issue is forgetting to update the pipeline every frame. If you create bodies and colliders but never call step, nothing moves. The engine is purely reactive. It waits for you to advance it.
Another trap is mixing up rigid body types. Rapier offers three categories. Dynamic bodies react to forces and collisions. Kinematic bodies move only when you explicitly set their position or velocity, and they push dynamic bodies out of the way. Fixed bodies are completely immovable. If you mark a platform as dynamic, it will fall through the floor unless you anchor it. If you mark a player as kinematic, gravity will not affect it. Pick the type that matches the intended behavior, not the one that feels right.
You will also run into lifetime issues when trying to hold references to bodies across frames. The RigidBodySet and ColliderSet use internal handles, not direct references. If you try to store a &RigidBody in your game state, the compiler will reject you with E0597 (borrowed value does not live long enough). The sets are designed to be mutated in place. Store the Handle instead. It is a lightweight index that survives across simulation steps.
Collision filtering is another frequent headache. By default, every collider touches every other collider. If you want a player to walk through a ghost or ignore certain debris, you must configure collision groups and solver groups. Rapier uses bitmasks for this. Setting them up requires bitwise logic, but it is the only way to control interaction rules. The community convention is to define your groups as constants at the top of your module. This keeps the bitmask math readable and prevents silent configuration errors. Follow the bitmask patterns exactly. The solver will not guess your intent.
When to reach for Rapier
Use Rapier when you need a fast, pure-Rust physics engine that integrates cleanly with modern Rust patterns. Use Rapier when you are building a game, a simulation, or a tool that requires rigid body dynamics, collision detection, and joint constraints. Use Rapier when you want deterministic simulation with a fixed timestep and predictable performance. Reach for a custom implementation only when you are building a highly specialized solver for a niche problem like soft body deformation or fluid dynamics. Reach for an ECS-integrated wrapper like bevy_rapier when you are already using the Bevy game engine and want automatic component synchronization. Pick a different engine like nphysics only if you have legacy code that depends on its specific API.
Where to go next
Physics engines are just one piece of a larger performance puzzle. Once your simulation runs smoothly, you will want to measure how it impacts your overall frame budget.
- How to Reduce Rust Compile Times
- Performance: Rust WASM vs JavaScript — When Is WASM Faster?
- How to Use cargo bench for Benchmarking in Rust
Keep your timestep fixed. Trust the handle system. Let the solver do the heavy lifting.