All tutorials Mighty Professional
Build a Game Engine ยท Simulation

Physics Simulation

Rigid bodies, collisions, and constraints: the runtime that makes crates stack, ragdolls flop, and vehicles stay on the road. Each piece is built up from integration through contact solving, with live widgets that show why explicit Euler blows up and what solver iterations and warm starting do to a stack of boxes. Cited sources from Euler 1765 to Jolt Physics.

Time~60 min LevelMid to senior; fundamentals review for physics-engine devs PrereqsVectors, dot/cross product, basic calculus (derivatives). Familiarity with C++ or Rust. HardwareNone. A feel for floating-point helps in the stability sections.
โ—‚ Build a Game Engine Phase 9 ยท Simulation Next ยท Spatial Partitioning โ–ธ

01Why simulate physics

A physics engine is the runtime that turns artist-placed geometry into something that responds to forces: crates that stack, ragdolls that collapse, vehicles that corner, destruction that propagates. The simulation usually runs at a fixed rate (commonly 50 or 60 Hz for gameplay, higher for vehicle dynamics and VR hand interaction) inside an outer variable-rate render loop. Every frame the engine (1) integrates forces into velocities and positions, (2) detects collisions between all pairs of overlapping shapes, (3) generates contact points where shapes touch, (4) solves a system of constraints that prevent penetration and enforce joints, and (5) writes the resulting transforms back for rendering.

The workload is dominated by step 4. A stack of 20 boxes generates roughly 80 contact constraints (4 contact points per face-to-face contact, 20 contacts), and the solver iterates over every constraint multiple times per frame. At 8 iterations and 120 Hz that is 76,800 constraint solves per second for a single stack. Scale to a destruction scene with thousands of fragments and the constraint solver becomes the frame-rate limiter.

The core loop is small. A minimal rigid body engine for convex shapes is on the order of a few thousand lines of C++. Most of those lines are in collision detection and constraint solving; integration is about 10 lines. Most of the engineering effort goes into stability, determinism, and performance at scale.

What you'll have by the end

A working mental model of every stage of the physics pipeline:

02A short history

1765
Euler's equations of motion. Leonhard Euler publishes the definitive treatment of rigid-body rotation in Theoria motus corporum solidorum seu rigidorum[16], relating angular momentum to torque. Rigid-body simulators still integrate these equations.
1907
Stรธrmer uses what is now called the Verlet method. Carl Stรธrmer applies it to the orbits of charged particles in the Earth's magnetic field. Loup Verlet[1] rediscovers it in 1967 for molecular dynamics simulations. The method is symplectic and does not require storing velocities explicitly.
1988
Gilbert, Johnson, and Keerthi publish GJK. "A fast procedure for computing the distance between complex objects in three-dimensional space"[2]. The algorithm computes the minimum distance between two convex shapes using their Minkowski difference and iterative simplex construction. Still the most widely used convex distance algorithm in game physics.
1994
Baraff publishes fast contact-force computation. David Baraff, "Fast Contact Force Computation for Nonpenetrating Rigid Bodies," SIGGRAPH 1994[3]. Contact forces with friction must satisfy a linear complementarity problem (LCP); the paper gives a pivoting algorithm derived from Dantzig's that solves it without general optimization software and proved faster and more reliable than earlier approaches. Baraff's 1997 SIGGRAPH course notes[4] remain a standard introduction to rigid-body simulation.
2005
Erin Catto presents "Iterative Dynamics with Temporal Coherence" at GDC.[5] Introduces an iterative, linear-time projected Gauss-Seidel solver for contact and friction, plus a contact cache that carries last step's impulses forward (warm starting). His GDC 2006 talk[10] recast the same method as sequential impulses with clamping of the accumulated impulse. The approach became the basis of Box2D, and Bullet and Jolt use the same solver family.
2007
Box2D released. Erin Catto releases Box2D as open source (Box2D Lite was demonstrated at GDC 2006)[6]. A 2D rigid-body engine that became the most widely read implementation of constraint-based game physics. Shipped in games such as Angry Birds, Limbo, and Crayon Physics Deluxe.
2006
Mรผller et al. publish Position Based Dynamics.[7] PBD solves constraints by directly modifying positions instead of computing impulses, which makes it controllable and stable for soft bodies and cloth, at the price of dissipating energy and of a stiffness that depends on iteration count and timestep. The authors built the real-time cloth simulator in AGEIA's physics library (later NVIDIA PhysX) on it.
2014
Catto presents "Understanding Constraints" at GDC.[8] A comprehensive walkthrough of the Jacobian formulation, sequential impulses, and the relationship between position correction and velocity constraints. A clear single source on how game constraint solvers work.
2022
Jorrit Rouwe releases Jolt Physics.[9] An open-source C++ rigid-body engine (public on GitHub since 2021, v1.0 in January 2022) with a multithreaded job-based pipeline, a SIMD-optimized solver, and a broad phase that bodies can update concurrently without locks. Its README lists Horizon Forbidden West and Death Stranding 2 among the games that use it.

03Integration methods

Integration is the step that turns forces into motion. Given a body with mass m, position x, velocity v, and net force F, Newton's second law gives the acceleration:

NEWTON II F = ma  →  a = Fm

The question is how to advance v and x by a discrete timestep dt. Four methods cover the space:

MethodUpdate ruleStabilityGame use
Explicit Euler x += v*dt, v += a*dt Unstable. Gains energy on oscillatory systems, diverges on springs. Almost never. A common beginner mistake.
Semi-implicit Euler v += a*dt, x += v*dt (velocity first) Symplectic. No secular energy drift; energy oscillates around the true value while the step is small enough (below 2/ω for a spring). First order, with some phase error. The usual choice in game engines (Box2D, Bullet, Jolt, PhysX); Catto's integration talk recommends it[11].
Velocity Verlet x += v*dt + 0.5*a*dt2; compute new a; v += 0.5*(a_old + a_new)*dt Symplectic, second-order accurate. Slightly better phase accuracy than semi-implicit Euler. Molecular dynamics. Rare in impulse-based rigid-body engines, whose contact solvers work directly on velocities and whose forces often depend on velocity (damping, friction), which the scheme assumes away.
RK4 Four force evaluations per step, weighted average. Fourth-order accurate but not symplectic. Exhibits secular energy drift on long simulations. Offline and scientific simulation. Rare in games: each of the four stages would need its own collision and constraint pass, and Catto's advice is that first-order accuracy is usually sufficient and RK4 can be ignored[11].

Order of operations matters. Explicit Euler updates position with the old velocity, then updates velocity. Semi-implicit Euler updates velocity first, then uses the new velocity to update position. That single reorder makes the integrator symplectic: among other things it preserves phase-space area, and for a conservative system it keeps the energy bounded instead of pumping it into oscillations. The widget below makes the difference visible.

Live ยท Integration method comparison (mass on spring)
Explicit Euler E / E₀
ยทยทยท
Semi-implicit E / E₀
ยทยทยท
Verlet E / E₀
ยทยทยท
Each panel plots phase space: position across, velocity divided by √k up, so the exact motion is a circle of radius 1 and E / E₀ is its energy relative to the start. Explicit Euler advances position with the old velocity and velocity with the force at the old position; for this spring (mass 1) every step multiplies the energy by exactly 1 + k·dt², so the orbit spirals outward and E / E₀ climbs without bound. Semi-implicit Euler moves position with the new velocity; its orbit is a slightly tilted ellipse that closes on itself, so E / E₀ wobbles near 1 without drifting. Velocity Verlet is second order and stays closer to the circle. Raise dt or k and explicit Euler diverges faster, since the growth factor is 1 + k·dt².

04The semi-implicit Euler step

The complete integration step for a single rigid body, including rotation, is:

SEMI-IMPLICIT EULER vnew = v + Fm · dt
xnew = x + vnew · dt

Position is updated with the new velocity. Rotation follows the same pattern: angular velocity omega is updated by the torque transformed by the inverse inertia tensor, and the orientation quaternion is advanced by omega. The code below drops the gyroscopic term ω × (Iω), as game engines commonly do by default; PhysX, for example, only adds it when a flag asks for it[12].

semi-implicit Euler step
// Semi-implicit Euler integration for a rigid body.
// Velocity is updated FIRST, then position uses the new velocity.
void integrate(RigidBody& body, float dt) {
    if (body.inverseMass == 0.0f) return;  // static body, skip

    // --- Linear ---
    // accumulate gravity and any applied forces into acceleration
    Vec3 linearAccel = body.force * body.inverseMass;
    body.linearVelocity += linearAccel * dt;

    // position advances using the NEW velocity (symplectic)
    body.position += body.linearVelocity * dt;

    // --- Angular ---
    // world-space inverse inertia times torque = angular acceleration.
    // The gyroscopic term omega x (I * omega) is left out, as many game
    // engines do by default; spinning bodies lose a little realism.
    Vec3 angularAccel = body.inverseInertiaTensorWorld * body.torque;
    body.angularVelocity += angularAccel * dt;

    // integrate orientation quaternion
    // dq/dt = 0.5 * omega * q  (quaternion multiplication)
    // spin is the pure quaternion (w = 0, xyz = omega)
    Quaternion spin(0.0f,
        body.angularVelocity.x,
        body.angularVelocity.y,
        body.angularVelocity.z);
    body.orientation += (spin * body.orientation) * (0.5f * dt);
    body.orientation.normalize();  // the additive update drifts off unit length

    // clear per-frame accumulators
    body.force  = Vec3(0.0f);
    body.torque = Vec3(0.0f);
}
/// Semi-implicit Euler integration for a rigid body.
/// Velocity is updated FIRST, then position uses the new velocity.
fn integrate(body: &mut RigidBody, dt: f32) {
    if body.inverse_mass == 0.0 { return; }  // static body, skip

    // --- Linear ---
    // accumulate gravity and any applied forces into acceleration
    let linear_accel = body.force * body.inverse_mass;
    body.linear_velocity += linear_accel * dt;

    // position advances using the NEW velocity (symplectic)
    body.position += body.linear_velocity * dt;

    // --- Angular ---
    // Same simplification as the C++ pane: no gyroscopic term.
    let angular_accel = body.inverse_inertia_tensor_world * body.torque;
    body.angular_velocity += angular_accel * dt;

    // integrate orientation quaternion
    // dq/dt = 0.5 * omega * q  (quaternion multiplication)
    let spin = Quat::from_xyzw(
        body.angular_velocity.x,
        body.angular_velocity.y,
        body.angular_velocity.z,
        0.0,
    );
    body.orientation = body.orientation + (spin * body.orientation) * (0.5 * dt);
    body.orientation = body.orientation.normalize();  // the additive update drifts off unit length

    // clear per-frame accumulators
    body.force  = Vec3::ZERO;
    body.torque = Vec3::ZERO;
}

05Collision detection: broad phase

With n bodies, testing every pair for collision is O(n2). At 5,000 bodies that is 12.5 million pairs per frame. The broad phase prunes the pair count to the small set of bodies whose bounding volumes actually overlap.

Three data structures dominate:

sweep-and-prune broad phase
// Sweep-and-prune on one axis.
// Returns candidate pairs whose AABBs overlap on the X axis.
// A second pass on Y (and Z for 3D) prunes further.
struct Endpoint {
    float value;       // x-coordinate of the AABB edge
    int   bodyIndex;   // which body this endpoint belongs to
    bool  isMin;       // true = left edge, false = right edge
};

std::vector<std::pair<int,int>> sweepAndPrune(
    std::vector<Endpoint>& endpoints)
{
    // insertion sort: O(n + swaps), so close to linear when the list is
    // still nearly sorted from last frame
    for (int i = 1; i < (int)endpoints.size(); ++i) {
        Endpoint key = endpoints[i];
        int j = i - 1;
        while (j >= 0 && endpoints[j].value > key.value) {
            endpoints[j + 1] = endpoints[j];
            --j;
        }
        endpoints[j + 1] = key;
    }

    // sweep: track which bodies are "open" (left edge seen, right edge not yet)
    std::vector<std::pair<int,int>> pairs;
    std::unordered_set<int> active;

    for (const auto& ep : endpoints) {
        if (ep.isMin) {
            // this body's AABB starts; it overlaps with every currently-active body
            for (int other : active) {
                int a = std::min(ep.bodyIndex, other);
                int b = std::max(ep.bodyIndex, other);
                pairs.push_back({a, b});
            }
            active.insert(ep.bodyIndex);
        } else {
            // this body's AABB ends
            active.erase(ep.bodyIndex);
        }
    }
    return pairs;
}
/// Sweep-and-prune on one axis.
/// Returns candidate pairs whose AABBs overlap on the X axis.
#[derive(Clone, Copy)]
struct Endpoint {
    value: f32,
    body_index: usize,
    is_min: bool,
}

fn sweep_and_prune(endpoints: &mut Vec<Endpoint>) -> Vec<(usize, usize)> {
    // insertion sort: O(n + swaps), so close to linear when the list is
    // still nearly sorted from last frame
    for i in 1..endpoints.len() {
        let key = endpoints[i];
        let mut j = i;
        while j > 0 && endpoints[j - 1].value > key.value {
            endpoints[j] = endpoints[j - 1];
            j -= 1;
        }
        endpoints[j] = key;
    }

    let mut pairs = Vec::new();
    let mut active = std::collections::HashSet::new();

    for ep in endpoints.iter() {
        if ep.is_min {
            for &other in &active {
                let a = ep.body_index.min(other);
                let b = ep.body_index.max(other);
                pairs.push((a, b));
            }
            active.insert(ep.body_index);
        } else {
            active.remove(&ep.body_index);
        }
    }
    pairs
}

06Narrow phase: GJK

The narrow phase tests the actual geometry of a candidate pair. For convex shapes, the dominant algorithm is GJK (Gilbert, Johnson, Keerthi, 1988[2]).

The idea: two convex shapes overlap if and only if their Minkowski difference contains the origin. GJK searches the Minkowski difference without constructing it explicitly, using support functions to probe specific directions. It builds a simplex (up to a tetrahedron in 3D) and checks whether it contains the origin.

If GJK reports overlap, EPA (Expanding Polytope Algorithm) takes the final simplex and expands it to find the penetration depth and contact normal.

Live ยท GJK visualizer
GJK iteration
0
simplex size
0
status
idle
Drag the shapes to move them. GJK builds a simplex on the Minkowski difference (right panel) one support point at a time. Each step picks a search direction, queries both support functions, computes the Minkowski-difference support point, and adds it to the simplex. If the simplex contains the origin, the shapes overlap (green). If the new support point did not pass the origin, the shapes are separated (red). In 2D an overlap can only be proved by a triangle, so this widget needs at least three support points to report one; a separation can be proved sooner.
GJK core loop
// Minkowski-difference support: farthest point on A in dir d, minus
// farthest point on B in dir -d. Declared first so gjkIntersect can call it.
Vec2 minkowskiSupport(const Shape& a, const Shape& b, Vec2 d) {
    return a.support(d) - b.support(-d);
}

// GJK intersection test for two convex shapes in 2D.
// Returns true if the shapes overlap.
bool gjkIntersect(const Shape& shapeA, const Shape& shapeB) {
    // pick an initial search direction (arbitrary; toward B's center works)
    Vec2 direction = shapeB.center() - shapeA.center();
    if (direction.lengthSquared() < 1e-8f) direction = Vec2(1.0f, 0.0f);

    // first support point on the Minkowski difference
    Vec2 support = minkowskiSupport(shapeA, shapeB, direction);
    Simplex simplex;
    simplex.add(support);

    // new search direction: toward the origin from the support point
    direction = -support;

    for (int iter = 0; iter < 32; ++iter) {
        Vec2 newPoint = minkowskiSupport(shapeA, shapeB, direction);

        // if the new point did not pass the origin, shapes are separated
        if (dot(newPoint, direction) < 0.0f) return false;

        simplex.add(newPoint);

        // check if the simplex contains the origin; if so, update direction
        if (simplex.containsOrigin(direction)) return true;
        // 'containsOrigin' also prunes the simplex to the closest feature
        // and sets 'direction' toward the origin from that feature
    }
    return false;  // iteration cap: only degenerate or touching cases get here
}
/// GJK intersection test for two convex shapes in 2D.
/// Returns true if the shapes overlap.
fn gjk_intersect(shape_a: &impl ConvexShape, shape_b: &impl ConvexShape) -> bool {
    let mut direction = shape_b.center() - shape_a.center();
    if direction.length_squared() < 1e-8 {
        direction = Vec2::X;
    }

    let support = minkowski_support(shape_a, shape_b, direction);
    let mut simplex = Simplex::new();
    simplex.add(support);
    direction = -support;

    for _iter in 0..32 {
        let new_point = minkowski_support(shape_a, shape_b, direction);

        // new point did not pass the origin: shapes are separated
        if new_point.dot(direction) < 0.0 { return false; }

        simplex.add(new_point);

        // simplex contains origin: shapes overlap
        if simplex.contains_origin(&mut direction) { return true; }
    }
    false
}

fn minkowski_support(a: &impl ConvexShape, b: &impl ConvexShape, d: Vec2) -> Vec2 {
    a.support(d) - b.support(-d)
}

07SAT (Separating Axis Theorem)

The SAT is the other major narrow-phase algorithm. For convex polytopes, it is often faster than GJK for simple shapes (boxes, triangles) because it directly produces the contact normal and penetration depth without a separate EPA pass.

The theorem: two convex shapes are separated if and only if there exists a line (an "axis") on which their projections do not overlap. For polygons, the candidate axes are the face normals of both shapes. For polyhedra in 3D, add the cross products of every edge pair between the two shapes. If all candidate axes show overlap, the shapes are colliding, and the axis with minimum overlap is the contact normal.

Live ยท SAT separating-axis demo
axes tested
0
overlap
ยทยทยท
contact normal
ยทยทยท
Drag either box and rotate B with the slider. Each dashed line is a candidate axis (a face normal) drawn through the middle of the canvas, and the blue and yellow bars on it are the two boxes' projections onto that axis. Two boxes need at most four axes in 2D, two per box, and only two when they share an orientation. If any pair of bars is disjoint, that axis separates the boxes, turns red, and the test stops there. When every pair overlaps, the axis with the smallest overlap (yellow) is the contact normal, pointing from A to B, and that overlap is the penetration depth. The red patch is the region where the boxes intersect; a manifold builder clips edges to find contact points inside it.

08Contact points and manifolds

A single collision between two boxes produces a contact manifold: a set of contact points, each with a position, normal, and penetration depth. Face-to-face contact between two boxes produces the intersection polygon of the two faces, up to eight vertices for two rectangles, which engines usually reduce to four points. Edge-to-edge generates 1 point.

Stable stacking requires persistent contact manifolds. The solver needs contacts to survive across frames so it can warm-start from the previous frame's solution. Contact point matching (by feature ID or by proximity) keeps the manifold stable as bodies shift slightly. Without persistence, every frame starts from scratch, and the solver needs many more iterations to converge.

Contact point reduction matters for performance. A face-to-face contact in 3D can produce an arbitrary polygon of contact points. Reducing it to 4 points, typically the deepest point plus points chosen to span the largest area in the contact plane, keeps the solver's constraint count bounded without losing stability.

09Constraint-based dynamics

A constraint is a restriction on how bodies may move. "These two bodies must not interpenetrate" is a contact constraint. "This body is attached to that body at a hinge" is a joint constraint. The constraint solver's job: find impulses that satisfy all active constraints simultaneously.

The formal framework (Baraff 1994[3], Catto 2005[5], Catto 2014[8]): each constraint is a scalar function C of the positions and orientations of the involved bodies.

CONSTRAINT C(x) = 0  ⇒  dCdt = Jv = 0  →  solved as   Jv + b = 0

Differentiating C gives the velocity constraint; the solver enforces it with an added bias term b that corrects drift. The J matrix (the Jacobian) maps body velocities to the rate of constraint violation. The solver computes an impulse lambda along each constraint's Jacobian direction such that the velocity-level constraint is satisfied.

Position drift (bodies slowly sinking into each other) is corrected by Baumgarte stabilization: add a bias term to the velocity constraint that is proportional to the current position error. For a contact, C is the signed separation along the normal, negative while the bodies overlap, so b comes out negative and J v = −b asks for a separating velocity.

BAUMGARTE b = betadt · C

10Sequential impulses

The sequential impulse solver (Catto 2005[5], 2006[10]) is a projected Gauss-Seidel iteration over the constraints. For each constraint:

  1. Compute the relative velocity at the contact point along the constraint direction: relVel = J * v.
  2. Compute the impulse magnitude: lambda = -(relVel + bias) * effectiveMass, where effectiveMass = 1 / (J M⁻¹ Jᵀ) is the mass the constraint direction sees.
  3. Clamp: for a contact, the accumulated impulse must be non-negative (you can push but not pull). This is accumulated impulse clamping, not per-iteration clamping.
  4. Apply the impulse to both bodies' velocities immediately.

Warm starting reuses the previous frame's accumulated impulses as the initial guess. Because the configuration changes little between frames, the warm-started guess is close to the converged answer, and the solver needs far fewer iterations. In Catto's 2005 tests at 10 iterations, a stack of three boxes without the contact cache slid apart and fell, while a stack of ten boxes with it settled within a few seconds and stayed up[5]. Matching contacts from one frame to the next is what makes this possible, which is why persistent manifolds (§8) matter.

Live ยท Sequential impulse solver (box stack)
bodies
0
contacts
0
max penetration
0
The solver is the linear-only one from the code below: eight boxes, one contact per touching pair, a 0.5-unit penetration slop. Max penetration is the deepest overlap between two bodies this step. With warm starting off, every step starts from zero impulses and the solver runs out of iterations before it finishes: after the stack settles, the deepest overlap is about 4.4 units at 1 iteration, 0.9 at 8 (the velocity count Box2D v2.4's manual suggests), and only reaches the slop around 30. With warm starting on, 4 iterations already hold it at about 0.3, because each step starts from the last step's answer. At 1 or 2 iterations with warm starting on, the stack bounces instead: the carried-over impulse, position-correction push included, is reapplied in full and there are too few passes to take the excess back out. Each iteration costs time linear in the number of contacts.
sequential impulse contact solver
// Sequential impulse solver for contact constraints.
// Iterates 'iterations' times over all contacts, accumulating impulses.
void solveContacts(
    std::vector<ContactConstraint>& contacts,
    std::vector<RigidBody>& bodies,
    int iterations, float dt)
{
    // warm start: apply last frame's accumulated impulses
    for (auto& contact : contacts) {
        Vec2 impulse = contact.normal * contact.accumulatedNormalImpulse
                     + contact.tangent * contact.accumulatedTangentImpulse;
        bodies[contact.bodyA].linearVelocity -= impulse * bodies[contact.bodyA].inverseMass;
        bodies[contact.bodyB].linearVelocity += impulse * bodies[contact.bodyB].inverseMass;
    }

    for (int iter = 0; iter < iterations; ++iter) {
        for (auto& contact : contacts) {
            RigidBody& bodyA = bodies[contact.bodyA];
            RigidBody& bodyB = bodies[contact.bodyB];

            // relative velocity at the contact along the normal (A to B);
            // positive means separating
            Vec2 relVel = bodyB.linearVelocity - bodyA.linearVelocity;
            float normalVel = dot(relVel, contact.normal);

            // Baumgarte bias b = (beta / dt) * C, where C is the signed
            // separation (negative while overlapping). The 0.01 slop lets
            // resting contacts overlap slightly instead of jittering. b <= 0,
            // so the impulse below asks for a separating velocity of -b.
            float separation = -contact.penetration;
            float bias = (0.2f / dt) * std::min(0.0f, separation + 0.01f);

            // impulse that drives J v + b to zero
            float lambda = -(normalVel + bias) * contact.effectiveMassNormal;

            // accumulated clamping: total impulse must be non-negative
            float oldAccum = contact.accumulatedNormalImpulse;
            contact.accumulatedNormalImpulse = std::max(0.0f, oldAccum + lambda);
            lambda = contact.accumulatedNormalImpulse - oldAccum;

            // apply impulse to both bodies
            Vec2 impulse = contact.normal * lambda;
            bodyA.linearVelocity -= impulse * bodyA.inverseMass;
            bodyB.linearVelocity += impulse * bodyB.inverseMass;

            // --- friction (Coulomb approximation) ---
            relVel = bodyB.linearVelocity - bodyA.linearVelocity;
            float tangentVel = dot(relVel, contact.tangent);
            float frictionLambda = -tangentVel * contact.effectiveMassTangent;

            // clamp friction to Coulomb cone
            float maxFriction = contact.friction * contact.accumulatedNormalImpulse;
            float oldFric = contact.accumulatedTangentImpulse;
            contact.accumulatedTangentImpulse = std::clamp(
                oldFric + frictionLambda, -maxFriction, maxFriction);
            frictionLambda = contact.accumulatedTangentImpulse - oldFric;

            Vec2 frictionImpulse = contact.tangent * frictionLambda;
            bodyA.linearVelocity -= frictionImpulse * bodyA.inverseMass;
            bodyB.linearVelocity += frictionImpulse * bodyB.inverseMass;
        }
    }
}
/// Sequential impulse solver for contact constraints.
/// Iterates `iterations` times over all contacts, accumulating impulses.
fn solve_contacts(
    contacts: &mut [ContactConstraint],
    bodies: &mut [RigidBody],
    iterations: usize, dt: f32,
) {
    // warm start: apply last frame's accumulated impulses
    for contact in contacts.iter() {
        let impulse = contact.normal * contact.accumulated_normal_impulse
                    + contact.tangent * contact.accumulated_tangent_impulse;
        bodies[contact.body_a].linear_velocity -= impulse * bodies[contact.body_a].inverse_mass;
        bodies[contact.body_b].linear_velocity += impulse * bodies[contact.body_b].inverse_mass;
    }

    for _iter in 0..iterations {
        for contact in contacts.iter_mut() {
            // relative velocity at the contact along the normal (A to B)
            let rel_vel = bodies[contact.body_b].linear_velocity
                        - bodies[contact.body_a].linear_velocity;
            let normal_vel = rel_vel.dot(contact.normal);

            // Baumgarte bias with the same sign convention as the C++ pane
            let separation = -contact.penetration;
            let bias = (0.2 / dt) * (separation + 0.01).min(0.0);
            let lambda = -(normal_vel + bias) * contact.effective_mass_normal;

            // accumulated clamping: total impulse must be non-negative
            let old_accum = contact.accumulated_normal_impulse;
            contact.accumulated_normal_impulse = (old_accum + lambda).max(0.0);
            let lambda = contact.accumulated_normal_impulse - old_accum;

            let impulse = contact.normal * lambda;
            bodies[contact.body_a].linear_velocity -= impulse * bodies[contact.body_a].inverse_mass;
            bodies[contact.body_b].linear_velocity += impulse * bodies[contact.body_b].inverse_mass;

            // --- friction (Coulomb approximation) ---
            let rel_vel = bodies[contact.body_b].linear_velocity
                        - bodies[contact.body_a].linear_velocity;
            let tangent_vel = rel_vel.dot(contact.tangent);
            let friction_lambda = -tangent_vel * contact.effective_mass_tangent;

            // clamp friction to Coulomb cone
            let max_friction = contact.friction * contact.accumulated_normal_impulse;
            let old_friction = contact.accumulated_tangent_impulse;
            contact.accumulated_tangent_impulse =
                (old_friction + friction_lambda).clamp(-max_friction, max_friction);
            let friction_lambda = contact.accumulated_tangent_impulse - old_friction;

            let friction_impulse = contact.tangent * friction_lambda;
            bodies[contact.body_a].linear_velocity -= friction_impulse * bodies[contact.body_a].inverse_mass;
            bodies[contact.body_b].linear_velocity += friction_impulse * bodies[contact.body_b].inverse_mass;
        }
    }
}
What's intentionally missing

The solver above is written to be read. A full one adds:

11Common constraints

Every constraint is a Jacobian row. The solver does not care what physical meaning the constraint has; it computes impulses that zero the velocity-level violation. The common constraints in a game engine:

12Substeps and fixed timestep

Physics usually runs at a fixed timestep, for reproducibility and because solver tuning and stability limits depend on dt. The standard pattern, popularized by Glenn Fiedler's "Fix Your Timestep!"[13]: an accumulator absorbs real elapsed time, and the physics loop ticks in fixed-size bites.

// Fixed-timestep accumulator pattern.
// 'elapsed' is wall-clock time since last render frame (variable).
// 'physDt' is the fixed physics timestep (e.g. 1/120 s).
accumulator += elapsed;
while (accumulator >= physDt) {
    stepPhysics(physDt);
    accumulator -= physDt;
}
// interpolation factor for rendering between the last two physics states
float alpha = accumulator / physDt;
renderState = lerp(previousPhysicsState, currentPhysicsState, alpha);

The rendering interpolation (the alpha lerp) smooths visual motion between discrete physics ticks. Without it, objects stutter whenever the render rate and the physics rate do not line up, because some frames get two physics steps and others none.

Variable dt breaks determinism. A variable dt comes from measured frame times, which differ from run to run and machine to machine, so the same inputs step through different sequences of dt values. Those sequences do not land on the same state: the integrator's error depends on the step size, and floating-point rounding means even one 1/30 s step and two 1/60 s steps disagree. Replays desync, networked physics diverges, and QA cannot reproduce bugs. A fixed dt is a prerequisite for reproducibility, though not sufficient on its own (see the floating-point pitfall in §15).

Substepping (running multiple physics ticks per frame with a smaller dt) improves stability for stiff constraints and high-speed objects. Jolt's PhysicsSystem::Update takes a number of collision steps that splits each update into smaller collision-plus-integration steps, and its documentation says the system is generally stable at 60 Hz with one[9]. PhysX's TGS solver substeps internally: it splits the step into as many substeps as there are position iterations and integrates after each, which its documentation calls conceptually similar to running PGS several times at a smaller timestep with one iteration each[12].

13Continuous collision detection

Discrete collision detection tests shapes at their positions at the end of the timestep. A fast-moving object can pass entirely through a thin wall between two frames. This is the tunneling problem: the object was on one side at t, on the other side at t + dt, and the collision detector never saw an overlap.

CCD fixes this by computing the TOI (time of impact): the earliest time during the timestep at which two swept shapes first touch. The simulation is advanced to the TOI, the collision is resolved, and the remainder of the timestep continues.

Speculative contacts are a cheaper alternative. Instead of computing exact TOI, the solver adds a constraint for any pair that will be within contact distance at the end of the step (based on current velocity). The bias velocity is set to prevent penetration at the predicted position. Speculative contacts catch much of the tunneling that discrete detection misses, at a fraction of the cost of swept-volume CCD. Jolt creates speculative contacts within a small distance (2 cm by default) during its normal discrete collision pass, and runs a swept cast only for bodies whose motion quality is set to LinearCast[9].

Live ยท CCD tunneling demo
mode
CCD
result
ยทยทยท
Uncheck CCD, raise the speed, and fire. The faint circles are the positions the collision test saw, one per 1/60 s step. The discrete test registers a hit only if one of those positions overlaps the wall, a window 20 units wide: 4 units of wall plus the ball's 16-unit diameter. Below 1,200 units/s a step is shorter than that window, so some position always lands in it and discrete detection catches the ball. Above it a single step can jump the whole window, and whether it does depends on where the steps happen to fall: from this start, 1,250, 1,500, 1,700, 1,900 and 1,950 units/s tunnel while the speeds between them are caught. With CCD on, the swept path is tested against the wall and every speed is caught.

14Case studies

1 ยท Box2D (Erin Catto)

Box2D[6] is the most widely read implementation of sequential-impulse dynamics. The v2.4 architecture is small enough to read end to end: a dynamic AABB tree for broad phase, GJK for distance queries, SAT-based contact generation for polygons, and the sequential-impulse solver with warm starting and accumulated clamping, run for a suggested 8 velocity and 3 position iterations per step[14]. Box2D's constraint formulation follows Catto's GDC presentations. Version 3 (2024) replaced the iteration counts with substepping and soft constraints. Shipped in 2D games such as Angry Birds, Limbo, and Crayon Physics Deluxe.

2 ยท Jolt Physics

Jolt Physics[9] is an open-source C++ rigid-body engine by Jorrit Rouwe (v1.0 in 2022). Its broad phase is a quadtree of AABBs, split into configurable layers, that bodies can update from several threads without locks. Narrow phase uses GJK and EPA. The solver is a SIMD-optimized sequential-impulse solver with warm starting (10 velocity and 2 position iterations by default), run per simulation island as jobs, with large islands split into groups that can be solved in parallel. It offers opt-in cross-platform determinism at roughly an 8% cost. Its README lists Horizon Forbidden West and Death Stranding 2 among the games that use it.

3 ยท PhysX: PGS and TGS

NVIDIA PhysX (open source) offers two iterative solvers, projected Gauss-Seidel (PGS) and TGS, both available on CPU and GPU[12]. PGS runs all its iterations on the same constraints and then integrates; TGS substeps, integrating after every position iteration, and the documentation calls it the generally recommended solver. The two also differ in friction: PGS clamps the two tangent directions separately, while TGS clamps their combined impulse. Gyroscopic forces are off unless a per-body flag enables them.

4 ยท Havok

Havok, now part of Microsoft, is a long-running commercial physics middleware whose solver internals are not publicly documented. Its site lists titles such as Assassin's Creed Odyssey, The Legend of Zelda: Breath of the Wild, and The Elder Scrolls IV: Oblivion Remastered among the games built on Havok Physics[15].

15Pitfalls

16What's next

Sources

  1. Loup Verlet. "Computer 'Experiments' on Classical Fluids. I. Thermodynamical Properties of Lennard-Jones Molecules." Physical Review 159(1):98-103, 1967. journals.aps.org/pr/abstract/10.1103/PhysRev.159.98. Rediscovery of Stรธrmer's integration method for molecular dynamics: a velocity-free position update whose energy stays bounded over long simulations.
  2. E. G. Gilbert, D. W. Johnson, S. S. Keerthi. "A fast procedure for computing the distance between complex objects in three-dimensional space." IEEE Journal of Robotics and Automation 4(2):193-203, 1988. ieeexplore.ieee.org/document/2083. The GJK algorithm for convex distance computation via iterative simplex construction on the Minkowski difference.
  3. David Baraff. "Fast Contact Force Computation for Nonpenetrating Rigid Bodies." Proc. SIGGRAPH 1994, pp. 23-34. dl.acm.org/doi/10.1145/192161.192168. A pivoting algorithm, derived from Dantzig's method for linear complementarity problems, that computes contact forces with static and dynamic friction without general optimization software, and that the paper reports as faster, simpler and more reliable than earlier approaches.
  4. David Baraff. "Physically Based Modeling: Rigid Body Simulation." SIGGRAPH 1997 Course Notes. cs.cmu.edu/~baraff/sigcourse. Course notes covering rigid-body equations of motion, collision response, and resting contact. A standard introduction to rigid-body simulation.
  5. Erin Catto. "Iterative Dynamics with Temporal Coherence." Game Developers Conference 2005. box2d.org (PDF). A linear-time projected Gauss-Seidel solver for contact and friction plus a contact cache for warm starting. Section 9 has the stacking results quoted in §10: with 10 iterations, three boxes without caching fell while ten boxes with caching stayed up.
  6. Erin Catto. "Box2D: A 2D Physics Engine for Games." box2d.org. Open-source 2D rigid-body engine, the most widely read implementation of sequential-impulse dynamics.
  7. Matthias Müller, Bruno Heidelberger, Marcus Hennix, John Ratcliff. "Position Based Dynamics." 3rd Workshop in Virtual Reality Interactions and Physical Simulation (VRIPHYS), 2006; journal version in J. Visual Communication and Image Representation 18(2), 2007. matthias-research.github.io/pages/publications/posBasedDyn.pdf. The PBD paper: constraint solving by direct position modification. The abstract notes the authors built a real-time cloth simulator for a games physics library on it.
  8. Erin Catto. "Understanding Constraints." Game Developers Conference 2014. box2d.org (PDF). A walkthrough of the Jacobian formulation, constraint derivation, and the sequential impulse solver.
  9. Jorrit Rouwe. "Jolt Physics" repository and Architecture documentation. github.com/jrouwe/JoltPhysics. Source for the Jolt claims on this page: the quadtree broad phase updated without locks, sequential impulses with warm starting, collision steps ("stable when running at 60 Hz with 1 collision step"), the Discrete and LinearCast motion qualities, the 2 cm default speculative contact distance and 10/2 default iteration counts (PhysicsSettings.h), the cross-platform determinism option at about 8% cost, and the README's list of games.
  10. Erin Catto. "Fast and Simple Physics using Sequential Impulses." Game Developers Conference 2006. box2d.org (PDF). The sequential-impulse presentation: clamp the accumulated impulse rather than each increment, and "apply old accumulated impulses at the beginning of the step" for "less iterations and greater stability."
  11. Erin Catto. "Numerical Methods." Game Developers Conference 2009. box2d.org (PDF). Explicit, symplectic and Verlet integration for games, with the advice that "first-order accuracy is usually sufficient for games" and that "you can safely ignore RK4."
  12. NVIDIA. "Rigid Body Dynamics," PhysX 5.4 documentation. nvidia-omniverse.github.io, with the broad-phase list in Rigid Body Collision. PGS versus TGS (TGS substeps once per position iteration; both run on CPU and GPU), per-patch friction handling, the gyroscopic-forces flag, sleeping, and the eSAP broad phase.
  13. Glenn Fiedler. "Fix Your Timestep!" Gaffer On Games, 2004. gafferongames.com. The accumulator loop and the render interpolation between the last two physics states used in §12.
  14. Erin Catto. Box2D v2.4.1 manual, "Hello Box2D." github.com/erincatto/box2d. "The suggested iteration count for Box2D is 8 for velocity and 3 for position."
  15. Havok. "Havok Physics." havok.com/havok-physics. Product page listing games built on Havok Physics.
  16. Leonhard Euler. Theoria motus corporum solidorum seu rigidorum. A. F. Röse, 1765 (Eneström index E289). Euler Archive. The treatise on rigid-body rotation behind Euler's equations of motion.

See also