10.1 Rigid Bodies & Collision

Phase 10 · Physics for Games · Study time: 30–50 h

Rigid-body dynamics, colliders, collision detection (broad and narrow phase), and why physics runs on a fixed timestep.

Everything so far has been about a single object moving through space: its position, its rotation, the vectors that describe it. This chapter is about what happens when many of those objects share the same space and start bumping into each other — a ball landing on the floor, a crate sliding into a wall, a stack of boxes staying stacked instead of falling through each other. That is rigid body physics, and it rests entirely on two things you already have: the calculus chapter's semi-implicit Euler stepping and fixed timestep, and the geometry chapter's vectors, dot products, and AABBs (axis-aligned bounding boxes). In fact, the geometry chapter's tip box about circles and boxes already used the exact words "broad phase" and "narrow phase" without explaining them. This chapter is that promise, paid off in full.

Every idea below follows the usual shape: a small runnable C++ program, its real printed output, then a plain explanation of what happened. Sections 1 through 11 build a tiny physics engine completely from scratch in C++, reusing the Vec2 struct and add/sub/scale/dot/length functions from the vectors chapter, so you can see exactly what is happening under the hood. Section 12 then switches to C# and shows how Unity's Rigidbody and Collider components do this same work for you, so you can recognize every concept inside the tool you will actually use day to day.

1. What is a rigid body?

A rigid body is an object whose shape never changes — the distance between any two points on it stays fixed no matter how it moves or spins. A crate, a ball, a car chassis: all rigid bodies. Cloth, rope, and skin that squashes are not — those are soft bodies, a different and harder topic this chapter does not cover. Restricting ourselves to shapes that never deform is exactly what makes rigid body physics tractable: instead of tracking every point on the object separately, you only need to track a handful of numbers for the whole object at once.

Those numbers are the rigid body's state:

angular velocity (spinning) (~) | velocity +-----------+ ----------> | | | mass | position = center of this box | m | rotation = which way it's turned | | +-----------+ state = { position, rotation, velocity, angularVelocity, mass }

Here is that state as a C++ struct, the foundation every later section builds on:

#include <iostream>

struct Vec2 { float x, y; };

struct RigidBody {
    Vec2 position;
    float rotation;         // 2D: a single angle, in radians
    Vec2 velocity;          // linear velocity, units per second
    float angularVelocity;  // 2D: radians per second
    float mass;
    float invMass;          // 1 / mass; 0 means "infinite mass", i.e. never moves
};

RigidBody makeBody(Vec2 pos, float mass) {
    RigidBody b;
    b.position = pos;
    b.rotation = 0.0f;
    b.velocity = {0.0f, 0.0f};
    b.angularVelocity = 0.0f;
    b.mass = mass;
    b.invMass = (mass > 0.0f) ? 1.0f / mass : 0.0f;
    return b;
}

int main() {
    RigidBody box   = makeBody({0.0f, 5.0f}, 2.0f);
    RigidBody floor = makeBody({0.0f, 0.0f}, 0.0f);   // mass 0 = static, never moves

    std::cout << "box:   mass=" << box.mass   << " invMass=" << box.invMass   << "\n";
    std::cout << "floor: mass=" << floor.mass << " invMass=" << floor.invMass << "\n";
}

Output:

box:   mass=2 invMass=0.5
floor: mass=0 invMass=0

Notice invMass (inverse mass, 1/mass) instead of storing mass alone. Every later section divides by "how much do these two bodies resist moving, combined" — and a wall or floor should never move no matter how hard something hits it. Representing "infinite mass" directly is impossible (you cannot divide by infinity), but representing "zero inverse mass" is trivial, and multiplying anything by zero cleanly gives zero movement. This one trick is why invMass, not mass, shows up in every formula for the rest of this chapter. A body with invMass = 0 is called static; anything else is dynamic.

Tip This chapter's demos stay in 2D and track only rotation/angularVelocity as plain numbers for simplicity. Turning a contact's push into a spin — applying torque, using a moment of inertia — is genuine extra math on top of everything here, and a natural next topic once this chapter's ideas click. Every concept below (integration, broad phase, narrow phase, contacts, impulses) works the same way in full 3D; only the rotation bookkeeping gets bigger.

2. Integrating motion: semi-implicit Euler, applied to a body

Recall the calculus chapter: a physics engine never solves the exact equations of motion. It approximates, one small fixed step at a time, using semi-implicit (symplectic) Euler — update velocity first from the current acceleration, then update position using the new velocity, not the old one. That one-line ordering is what keeps energy bounded instead of leaking away or exploding, and it is exactly what every real physics engine does every single step. Here it is applied to a RigidBody, stepping under gravity:

#include <iostream>

struct Vec2 { float x, y; };

struct RigidBody {
    Vec2 position;
    Vec2 velocity;
    float invMass;
};

void integrate(RigidBody& body, Vec2 gravity, float dt) {
    if (body.invMass <= 0.0f) return;         // static bodies never move

    // 1. velocity FIRST, using the current acceleration (here: just gravity)
    body.velocity.x += gravity.x * dt;
    body.velocity.y += gravity.y * dt;

    // 2. position using the NEW velocity -- this is what makes it semi-implicit
    body.position.x += body.velocity.x * dt;
    body.position.y += body.velocity.y * dt;
}

int main() {
    RigidBody ball;
    ball.position = {0.0f, 0.0f};
    ball.velocity = {3.0f, 0.0f};
    ball.invMass  = 1.0f;

    Vec2 gravity = {0.0f, -10.0f};
    float dt = 0.1f;

    for (int tick = 1; tick <= 4; tick++) {
        integrate(ball, gravity, dt);
        std::cout << "tick " << tick << ": pos=(" << ball.position.x << ", " << ball.position.y
                   << ") vel=(" << ball.velocity.x << ", " << ball.velocity.y << ")\n";
    }
}

Output:

tick 1: pos=(0.3, -0.1) vel=(3, -1)
tick 2: pos=(0.6, -0.3) vel=(3, -2)
tick 3: pos=(0.9, -0.6) vel=(3, -3)
tick 4: pos=(1.2, -1) vel=(3, -4)

If step 2 instead used the old velocity to move position (explicit Euler, the unstable variant the calculus chapter warned about), the y positions after the same four ticks would be 0, -0.1, -0.3, -0.6 instead of -0.1, -0.3, -0.6, -1.0 — a full step behind, because it is always moving position with a velocity that has not "seen" this step's gravity yet. Same amount of arithmetic, same input numbers, meaningfully different (and less stable) trajectory. This is exactly the one-line ordering the game loop chapter's FixedUpdate example already used, without naming it — updating velocityY before using it to move transform.position.

Explicit Euler: watch the energy blow up

Why insist on the "velocity first" ordering? Because the other order — explicit (forward) Euler, which moves position with the old velocity and only then updates velocity — does not merely lag by a step; on anything that oscillates it silently adds energy every step until the simulation explodes. The clearest place to see it is a spring: a mass pulled back toward the origin with acceleration a = -k*x, which in the real world swings forever at a constant amplitude. Run both integrators on that exact spring and print the total energy (0.5*(v*v + k*x*x), which physically must stay constant):

#include <cstdio>
#include <cmath>

int main() {
    float k = 1.0f, dt = 0.2f;      // spring constant, fixed step
    float ex = 1.0f, ev = 0.0f;     // explicit Euler: position, velocity
    float sx = 1.0f, sv = 0.0f;     // semi-implicit Euler: position, velocity

    printf("step | explicit x   energy | semi-impl x   energy\n");
    for (int step = 0; step <= 40; step++) {
        if (step % 8 == 0) {
            float eE = 0.5f * (ev * ev + k * ex * ex);
            float sE = 0.5f * (sv * sv + k * sx * sx);
            printf("%3d  | %9.4f  %.4f | %9.4f  %.4f\n",
                   step, ex, eE, sx, sE);
        }
        // explicit (forward) Euler: move x with OLD v, then v with OLD x
        float exNew = ex + ev * dt;
        float evNew = ev - k * ex * dt;
        ex = exNew; ev = evNew;
        // semi-implicit: v FIRST (current x), then x with the NEW v
        sv = sv - k * sx * dt;
        sx = sx + sv * dt;
    }
}

Output:

step | explicit x   energy | semi-impl x   energy
  0  |    1.0000  0.5000 |    1.0000  0.5000
  8  |   -0.0098  0.6843 |   -0.1323  0.5133
 16  |   -1.3684  0.9365 |   -0.9916  0.4936
 24  |    0.0402  1.2817 |    0.1955  0.5196
 32  |    1.8719  1.7540 |    0.9791  0.4875
 40  |   -0.0917  2.4005 |   -0.2580  0.5256

The spring should hold a constant energy of 0.5 forever. Semi-implicit Euler (right two columns) does exactly that — its energy wobbles a hair around 0.5 (0.49 to 0.53) and never drifts, so the mass keeps swinging between about -1 and +1 for as long as you run it. Explicit Euler (left two columns) instead climbs without stopping: 0.50, 0.68, 0.94, 1.28, 1.75, 2.40 — nearly five times the starting energy after only 40 steps, its swing growing wider every cycle. Give it a few hundred more steps and the numbers run off to infinity. This is not a rounding artifact you can shrink away by using double; it is a directional bias baked into the ordering. It is the reason no shipping engine uses explicit Euler for dynamics, and why the calculus chapter's one-line reordering matters so much.

Tip The technical name for what semi-implicit Euler has and explicit Euler lacks is being symplectic: it conserves a quantity very close to the true energy, so error stays bounded and oscillates instead of piling up in one direction. You do not need the theory — the rule of thumb is enough: for anything springy or orbiting, an integrator that gains energy will eventually detonate and one that loses energy will grind to a halt; symplectic integrators do neither.

Verlet integration: the velocity you never store

A third integrator is worth knowing because a whole family of effects — cloth, rope, hair, and position-based dynamics — is built on it: Verlet integration. Its trick is to not store velocity at all. Instead it keeps the previous position and infers motion from the gap between "where I was" and "where I am":

next = current + (current - previous) + acceleration * dt*dt \________/ \_________________/ carry on the implied velocity is just last step's (inertia) movement, (current - previous) -- no v stored
#include <cstdio>

int main() {
    float g = -10.0f, dt = 0.1f;   // gravity, fixed step
    float y     = 0.0f;            // current position (start at rest at 0)
    float yPrev = 0.0f;            // previous position; at rest it equals y

    printf("step | y (Verlet) | implied v = (y - yPrev)/dt\n");
    for (int step = 1; step <= 5; step++) {
        // position Verlet: next = 2*current - previous + a*dt*dt
        float yNext = 2.0f * y - yPrev + g * dt * dt;
        float impliedV = (yNext - y) / dt;   // velocity is never stored -- it is inferred
        yPrev = y;
        y = yNext;
        printf("%3d  | %8.2f  | %8.1f\n", step, y, impliedV);
    }
}

Output:

step | y (Verlet) | implied v = (y - yPrev)/dt
  1  |    -0.10  |     -1.0
  2  |    -0.30  |     -2.0
  3  |    -0.60  |     -3.0
  4  |    -1.00  |     -4.0
  5  |    -1.50  |     -5.0

Those y values — -0.10, -0.30, -0.60, -1.00, -1.50 — are the exact same fall semi-implicit Euler produced two listings above, reached without ever storing a velocity variable. Verlet is symplectic too (it will not blow the spring up), and it has one property that makes it the default for cloth and rope: because there is no separate velocity to contradict the position, you can grab a point and move it — pin a flag's corner, snap a rope back to its maximum length — and the next step's (current - previous) automatically turns that change into a sensible velocity. Correcting positions directly and letting the integrator recover velocity for free is the whole idea behind position-based dynamics.

Why physics insists on a fixed step

The game loop chapter showed Update's deltaTime jittering frame to frame, and the calculus chapter built a full fixed-timestep-plus-accumulator loop to fix it. Collision detection is exactly why physics cannot skip that machinery. Three concrete reasons stack on top of each other:

So every body in the world gets integrated by the same fixed dt inside the same physics step — which sets up the rest of this chapter perfectly: once every body's new position for this step is known, the engine has to figure out which of them now overlap.

The accumulator: one wobbly frame time, whole fixed steps

"Use a fixed dt" runs straight into a wall: real frames do not arrive on a fixed schedule. One frame takes 12 ms, the next 22 ms, the next 9 ms, depending on what the GPU and the rest of the game are doing that instant. If you simply passed each frame's real duration to integrate, physics would step by a different dt every frame — and Euler's answer depends on dt, so the same motion would land in a different place depending on how the frames happened to fall:

#include <iostream>

// integrate a falling body for a total of 1.0s, split into `steps` equal parts
float fallFor(float totalTime, int steps) {
    float y = 0.0f, v = 0.0f, g = -10.0f;
    float dt = totalTime / steps;
    for (int i = 0; i < steps; i++) {
        v += g * dt;      // semi-implicit Euler
        y += v * dt;
    }
    return y;
}

int main() {
    std::cout << "1.0s of gravity, stepped different ways:\n";
    std::cout << "  1 step  of 1.00s : y = " << fallFor(1.0f, 1) << "\n";
    std::cout << "  4 steps of 0.25s : y = " << fallFor(1.0f, 4) << "\n";
    std::cout << "  10 steps of 0.10s: y = " << fallFor(1.0f, 10) << "\n";
}

Output:

1.0s of gravity, stepped different ways:
  1 step  of 1.00s : y = -10
  4 steps of 0.25s : y = -6.25
  10 steps of 0.10s: y = -5.5

One second of the same gravity lands the body at -10, or -6.25, or -5.5, purely depending on how the second was chopped up (smaller steps are closer to the true answer of -5.0, but that is not the point here). That spread is the non-determinism: hand physics whatever dt the frame happened to take, and the result is at the mercy of frame timing — impossible to reproduce for a replay, and guaranteed to drift two networked players apart. The fix is the accumulator: add each frame's real elapsed time to a running total, then spend that total in fixed-size chunks, carrying whatever is left over into the next frame.

#include <iostream>

int main() {
    const float FIXED = 0.25f;   // physics ALWAYS steps by this, never anything else
    float acc = 0.0f;
    float frameTimes[] = {0.30f, 0.10f, 0.28f, 0.40f};   // jittery real frame durations

    for (int f = 0; f < 4; f++) {
        acc += frameTimes[f];
        int stepsThisFrame = 0;
        while (acc >= FIXED) {
            acc -= FIXED;        // integrate(FIXED) would run here
            stepsThisFrame++;
        }
        std::cout << "  frame dt=" << frameTimes[f]
                  << " -> ran " << stepsThisFrame
                  << " fixed step(s), leftover=" << acc << "\n";
    }
}

Output:

  frame dt=0.3 -> ran 1 fixed step(s), leftover=0.05
  frame dt=0.1 -> ran 0 fixed step(s), leftover=0.15
  frame dt=0.28 -> ran 1 fixed step(s), leftover=0.18
  frame dt=0.4 -> ran 2 fixed step(s), leftover=0.08

The frame times jitter wildly — 0.30, 0.10, 0.28, 0.40 — but every physics step is exactly 0.25. A long frame (0.40) runs two steps to catch up; a short one (0.10) runs zero steps and just banks its time in the accumulator for later. Physics never once sees a variable dt, so it stays fully deterministic no matter how the frames stutter. This is exactly what Unity's FixedUpdate does: it is called zero, one, or several times per rendered frame so physics always advances in Time.fixedDeltaTime-sized chunks. The one wrinkle to know: the render frame usually lands between two fixed steps (that leftover sitting in the accumulator), so smooth engines interpolate the visual position between the last two physics states — otherwise a fast object visibly stutters even though the simulation underneath is perfect.

3. Broad phase: don't test everything against everything

With n bodies in the world, there are n * (n-1) / 2 possible pairs that might be touching. Testing every single pair with an exact shape test is wasteful: 100 bodies means 4,950 pairs checked every physics step, and almost all of them are nowhere near each other. Broad phase collision detection's only job is to throw out the obviously-not-touching pairs fast and cheap, using the AABBs from the geometry chapter, leaving a much smaller list of candidate pairs for an exact test.

ALL POSSIBLE PAIRS BROAD PHASE (cheap AABB test) (n bodies -> n*(n-1)/2 pairs) throws out pairs that obviously cannot be touching A---B---C---D A---B C D \ / \ / \ / (kept: (dropped: AABBs X X X 6 pairs checked AABBs don't even / \ / \ / \ overlap) overlap) A---C B---D (and A-D, B-C too) narrow phase (section 4) only runs on what survives broad phase

Here are four circles, using the Circle/AABB structs from the geometry chapter, with a naive broad phase that checks every pair's AABB overlap:

#include <iostream>

struct Vec2 { float x, y; };
struct AABB { Vec2 min, max; };

AABB circleAABB(Vec2 center, float radius) {
    return { {center.x - radius, center.y - radius},
             {center.x + radius, center.y + radius} };
}

bool aabbOverlap(AABB a, AABB b) {
    if (a.max.x < b.min.x || a.min.x > b.max.x) return false;
    if (a.max.y < b.min.y || a.min.y > b.max.y) return false;
    return true;
}

int main() {
    struct Named { const char* name; Vec2 center; float radius; };
    Named bodies[] = {
        {"A", {0.0f, 0.0f}, 1.0f},
        {"B", {1.7f, 0.0f}, 1.0f},
        {"C", {5.0f, 5.0f}, 1.0f},
        {"D", {0.0f, 3.0f}, 1.0f},
    };

    int pairsChecked = 0, candidates = 0;
    for (int i = 0; i < 4; i++) {
        for (int j = i + 1; j < 4; j++) {
            AABB boxI = circleAABB(bodies[i].center, bodies[i].radius);
            AABB boxJ = circleAABB(bodies[j].center, bodies[j].radius);
            pairsChecked++;
            if (aabbOverlap(boxI, boxJ)) {
                std::cout << bodies[i].name << "-" << bodies[j].name << ": CANDIDATE\n";
                candidates++;
            }
        }
    }
    std::cout << "checked " << pairsChecked << " pairs, found " << candidates << " candidate(s)\n";
}

Output:

A-B: CANDIDATE
checked 6 pairs, found 1 candidate(s)

Six pairs checked, one survives. That one pair — A and B — is the only one narrow phase (section 4) needs to bother with. This naive version is still O(n^2) in how many pairs it checks (it just makes each check cheap), which is fine for a handful of objects but starts to add up once a scene has hundreds of bodies. Two common ways to avoid checking every pair at all:

Uniform grid: sort bodies into cells

Divide the world into fixed-size square cells and drop each body into whichever cell its center falls in (using floor(position / cellSize)). Only bodies sharing a cell (or a neighboring one, for objects near a cell edge) ever get compared — bodies in far-apart cells are never even considered:

y ^ +------+------+------+ 4 | D | | | cell size = 2 +------+------+------+ 2 | A B | | C | A and B land in the SAME cell (0,0) +------+------+------+ -> only they get compared 0 | | | | +------+------+------+---> x 0 2 4 6
#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };

int cellOf(float coord, float cellSize) {
    return (int)std::floor(coord / cellSize);
}

int main() {
    struct Named { const char* name; Vec2 center; };
    Named bodies[] = {
        {"A", {0.0f, 0.0f}},
        {"B", {1.7f, 0.0f}},
        {"C", {5.0f, 5.0f}},
        {"D", {0.0f, 3.0f}},
    };
    float cellSize = 2.0f;

    for (auto& b : bodies) {
        int cx = cellOf(b.center.x, cellSize);
        int cy = cellOf(b.center.y, cellSize);
        std::cout << b.name << ": cell (" << cx << ", " << cy << ")\n";
    }
}

Output:

A: cell (0, 0)
B: cell (0, 0)
C: cell (2, 2)
D: cell (0, 1)

A and B land in the same cell; C and D each land alone. A real implementation groups bodies by cell (typically with a hash map keyed on the cell coordinates) and only tests pairs that share a cell or a neighboring one — the same one candidate pair the AABB sweep found, reached a different way. A grid is simple and fast when objects are roughly evenly spread out, but wastes memory on mostly-empty cells if objects cluster tightly in one area or spread across a huge, sparse world.

Sweep and prune: sort along one axis

Sweep and prune (sometimes called the "sort and sweep" method) takes a different angle: sort every body's AABB by its minimum x coordinate, then walk the sorted list left to right, keeping track of which AABBs are currently "open" (their span has started but not yet ended). Two AABBs can only possibly overlap if they are both open at the same time:

sorted by minX: A |-----| B |-----| D |---| C |---| -----------------------> x sweeping left to right: A opens, B opens while A is still open (A,B pair!), A closes, B closes, D opens (nothing else open -- no pair), D closes, C opens (nothing open -- no pair) only the A-B overlap in x needs a y check -- same result as the grid and the AABB sweep above, found by sorting instead of bucketing

Engines like Box2D favor sweep and prune because, frame to frame, an object's neighbors rarely change — the sorted order from last frame is already almost correct, so re-sorting is nearly free (a handful of swaps, not a full sort) most steps. Whichever method a broad phase uses, its job is always the same: turn "all possible pairs" into "candidate pairs," fast, so the exact (and much more expensive) test in the next section only has to run a handful of times, not thousands.

Common mistake Skipping broad phase entirely "because the scene is small right now." A scene with 20 objects does not feel slow with a naive all-pairs check. The same code with 2,000 objects (a busy battle scene, a crowd, a destructible wall broken into fragments) suddenly runs O(n^2) exact shape tests every single physics step, and frame rate falls off a cliff. Build the broad-phase habit early, before it is a fire to put out.

4. Narrow phase: the exact test

Narrow phase collision detection takes the small list of candidate pairs broad phase produced and runs an exact, shape-specific test on each one: do these two shapes actually overlap, and if so, by how much? For two circles this is simple — the exact test the geometry chapter's point-in-circle idea generalizes directly to:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };

Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }

bool circlesOverlap(Vec2 centerA, float rA, Vec2 centerB, float rB) {
    float dist = length(sub(centerB, centerA));
    return dist < (rA + rB);
}

int main() {
    Vec2 A = {0.0f, 0.0f}, B = {1.7f, 0.0f};
    std::cout << "A-B exact test: " << (circlesOverlap(A, 1.0f, B, 1.0f) ? "COLLIDING" : "clear") << "\n";
}

Output:

A-B exact test: COLLIDING

Broad phase already told us A and B were the only pair worth checking, so narrow phase only has one test to run — not the six a naive all-pairs check would have needed. "Two centers closer together than the sum of their radii" is the entire exact test for circles, which is why circles (and spheres in 3D) are the cheapest shape a physics engine can collide.

Circle versus box: clamp to the nearest point

Between "two circles" and "two polygons" sits a case common enough to earn its own shortcut: a circle against an axis-aligned box. The exact test is a tidy trick — find the point on the box closest to the circle's center by clamping the center into the box's range on each axis, then it is just a circle-versus-point check from there:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };
struct AABB { Vec2 min, max; };

float clampf(float v, float lo, float hi) {
    return v < lo ? lo : (v > hi ? hi : v);
}

int main() {
    AABB box = { {0.0f, 0.0f}, {3.0f, 2.0f} };
    Vec2 center = {3.5f, 1.0f};
    float radius = 1.0f;

    // closest point on the box to the circle center = clamp center into the box
    Vec2 closest = { clampf(center.x, box.min.x, box.max.x),
                     clampf(center.y, box.min.y, box.max.y) };
    Vec2 d = { center.x - closest.x, center.y - closest.y };
    float dist = std::sqrt(d.x * d.x + d.y * d.y);

    std::cout << "closest point on box = (" << closest.x << ", " << closest.y << ")\n";
    std::cout << "distance to center   = " << dist << "\n";
    if (dist < radius) {
        Vec2 normal = { d.x / dist, d.y / dist };   // from box toward circle
        float penetration = radius - dist;
        std::cout << "COLLIDING  normal=(" << normal.x << ", " << normal.y
                  << ")  penetration=" << penetration << "\n";
    } else {
        std::cout << "clear\n";
    }
}

Output:

closest point on box = (3, 1)
distance to center   = 0.5
COLLIDING  normal=(1, 0)  penetration=0.5

The circle sits at x=3.5, just past the box's right edge at x=3. Clamping the center into the box's [0,3] x [0,2] range snaps it to (3, 1) — the nearest point on the box — which is 0.5 away, inside the radius of 1, so they collide by 0.5 with a normal pointing straight out along +x. The one case this simple version does not handle is the center being inside the box (then the clamp returns the center itself, dist is 0, and you cannot divide to get a normal); a full implementation detects that and pushes out along the nearest face instead. Engines keep hand-written shortcuts like this for circle-vs-circle, circle-vs-box, and box-vs-box precisely because they are this cheap.

Boxes and polygons: the Separating Axis Theorem

Circles are the easy case. For boxes and other convex polygons (a shape is convex if a straight line between any two points inside it never leaves the shape), the classic exact test is the Separating Axis Theorem (SAT): two convex shapes are not colliding if and only if you can find at least one axis where projecting both shapes onto it leaves a gap between them. Try every candidate axis (for polygons, that means every edge's perpendicular normal); if every single one shows overlap, the shapes are colliding, and the axis with the smallest overlap tells you the collision normal.

SEPARATING AXIS FOUND -> NOT colliding NO separating axis -> COLLIDING [A] axis [A] [B] [A] \ / \ [ [B] ] overlapping on \ / \ every axis tried [B] \/ gap between every projected shadow /\ A's shadow and overlaps every other -- / \ B's shadow on no gap exists anywhere this axis

SAT in code: projecting two boxes onto an axis

Here is that theorem as a runnable test. For each candidate axis (every edge normal of both shapes), project both polygons onto it — reduce each to the [min, max] shadow it casts on that axis — and check whether the shadows overlap. The moment one axis shows a gap, stop: the shapes are apart. If every axis overlaps, they collide, and the axis with the smallest overlap is the collision normal, its overlap the penetration depth:

#include <iostream>
#include <cmath>
#include <vector>

struct Vec2 { float x, y; };
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

// project every vertex of `poly` onto `axis`, return the [min,max] shadow
void projectOnto(const std::vector<Vec2>& poly, Vec2 axis, float& mn, float& mx) {
    mn = mx = dot(poly[0], axis);
    for (const Vec2& v : poly) {
        float p = dot(v, axis);
        if (p < mn) mn = p;
        if (p > mx) mx = p;
    }
}

bool satOverlap(const std::vector<Vec2>& A, const std::vector<Vec2>& B,
                Vec2& normal, float& depth) {
    depth = 1e30f;
    for (int poly = 0; poly < 2; poly++) {               // axes from BOTH shapes
        const std::vector<Vec2>& P = (poly == 0) ? A : B;
        for (size_t i = 0; i < P.size(); i++) {
            Vec2 a = P[i], b = P[(i + 1) % P.size()];
            Vec2 edge = { b.x - a.x, b.y - a.y };
            Vec2 axis = { -edge.y, edge.x };             // edge normal (perpendicular)
            float len = std::sqrt(axis.x * axis.x + axis.y * axis.y);
            axis = { axis.x / len, axis.y / len };
            float minA, maxA, minB, maxB;
            projectOnto(A, axis, minA, maxA);
            projectOnto(B, axis, minB, maxB);
            float overlap = std::min(maxA, maxB) - std::max(minA, minB);
            if (overlap <= 0.0f) return false;           // a gap -> separated, done
            if (overlap < depth) { depth = overlap; normal = axis; }
        }
    }
    return true;
}

void test(const char* name, std::vector<Vec2> A, std::vector<Vec2> B) {
    Vec2 n; float d;
    if (satOverlap(A, B, n, d))
        std::cout << name << ": COLLIDING  normal=(" << n.x << ", " << n.y
                  << ")  depth=" << d << "\n";
    else
        std::cout << name << ": separated (a gap axis was found)\n";
}

int main() {
    std::vector<Vec2> A = {{0,0},{2,0},{2,2},{0,2}};                 // axis-aligned square
    std::vector<Vec2> Bhit  = {{2.8f,0},{3.8f,1},{2.8f,2},{1.8f,1}}; // diamond, overlaps A
    std::vector<Vec2> Bmiss = {{4.2f,0},{5.2f,1},{4.2f,2},{3.2f,1}}; // diamond, clears A
    test("overlapping", A, Bhit);
    test("separated  ", A, Bmiss);
}

Output:

overlapping: COLLIDING  normal=(-1, 0)  depth=0.2
separated  : separated (a gap axis was found)

The second box is a diamond (a square turned 45 degrees), so this is genuine SAT, not a disguised AABB check — projecting onto the diamond's slanted edge normals is real work. In the overlapping case, square A spans x in [0, 2] and the diamond's leftmost tip sits at x=1.8, so their shadows on the x-axis overlap by just 0.2 — smaller than the overlap on any other axis tried — so that axis wins as the collision normal with a penetration of 0.2. (The sign of the normal is whichever edge produced it; a real engine flips it to always point from A toward B, so here it would report (1, 0).) In the separated case the diamond has been slid right until its tip clears A's edge, and the very first axis showing a gap makes satOverlap return early — that early-out is what makes SAT fast in the common not-touching case.

General convex shapes: GJK

SAT works cleanly for polygons because "try every edge normal" is a short, known list of axes. For a general convex shape — a rounded capsule, an arbitrary convex mesh — there is no small fixed list of axes to try. The standard answer is the GJK algorithm (Gilbert-Johnson-Keerthi, named after its three authors), which sidesteps the axis-search problem entirely. GJK works on the Minkowski difference of the two shapes (informally: "shape A minus shape B," built by subtracting every point of B from every point of A) — a beautiful fact about this construction is that the two original shapes overlap if and only if this combined shape contains the origin point (0,0).

shape A shape B Minkowski difference (A - B) /\ __ __________ / \ / \ / \ /____\ /____\ / origin? \ / (0,0) here? \ \________________/ A and B overlap in the real world <=> (0,0) is INSIDE (A - B)

GJK never has to build the whole Minkowski difference shape (which can be huge). Instead it uses a support function — "give me the point of this shape furthest in direction D" — to grow a small triangle (a simplex) step by step, each time picking a new direction that moves the simplex closer to enclosing the origin. If the simplex ever encloses the origin, the shapes overlap; if the search direction ever points away from the origin with no way to get closer, they do not. The full algorithm is genuinely intricate to implement correctly, which is exactly why almost nobody hand-writes it — Unity's PhysX backend, Unreal's Chaos physics, and virtually every general-purpose physics engine ships a battle-tested GJK (often paired with a related algorithm, EPA, to recover penetration depth once GJK confirms overlap). What matters for you is recognizing the name and knowing what problem it solves: the exact overlap test for general convex shapes, when a short list of axes like SAT's is not available.

Tip Circle vs circle, circle vs box, and box vs box are common enough that most engines special-case them with the direct formulas (like section 4's circlesOverlap) rather than routing them through GJK — it is faster to skip the general machinery when a shortcut is known. GJK is what handles everything else: capsules, convex hulls, arbitrary convex meshes.

5. Building a contact: point, normal, penetration depth

A yes/no "colliding" answer is not enough to actually resolve a collision — the engine needs to know exactly where the shapes touch and which way to push them apart. Narrow phase's real output, once it confirms an overlap, is a small struct called a contact (or manifold when a single pair produces more than one contact point, like a box resting flat on a floor):

A B ***** ***** * * * * * o----+-- normal --+----o * normal: points from A toward B * *| |* * point: roughly where the surfaces meet ***** |<--penetr->| ***** penetration: how far they overlap | -ation | | (overlap)|

For two circles, every one of those three pieces falls straight out of the exact test from section 4 — the center-to-center line already is the normal direction:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };

Vec2 add(Vec2 a, Vec2 b) { return { a.x + b.x, a.y + b.y }; }
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }

struct Contact {
    Vec2 point;
    Vec2 normal;       // points from A toward B
    float penetration;
};

Contact makeCircleContact(Vec2 posA, float rA, Vec2 posB, float rB) {
    Vec2 delta = sub(posB, posA);
    float dist = length(delta);
    Vec2 normal = scale(delta, 1.0f / dist);

    Vec2 pointOnA = add(posA, scale(normal, rA));
    Vec2 pointOnB = sub(posB, scale(normal, rB));
    Vec2 midpoint = scale(add(pointOnA, pointOnB), 0.5f);

    float penetration = (rA + rB) - dist;
    return { midpoint, normal, penetration };
}

int main() {
    Contact c = makeCircleContact({0.0f, 0.0f}, 1.0f, {1.7f, 0.0f}, 1.0f);
    std::cout << "point=(" << c.point.x << ", " << c.point.y << ")\n";
    std::cout << "normal=(" << c.normal.x << ", " << c.normal.y << ")\n";
    std::cout << "penetration=" << c.penetration << "\n";
}

Output:

point=(0.85, 0)
normal=(1, 0)
penetration=0.3

A (center at the origin) and B (center 1.7 units to the right) overlap by 0.3 units, the contact sits at the midpoint of that overlap, and the normal points straight from A toward B. Every remaining section in this chapter works entirely off this one small struct — it is the hand-off point between "did they collide" (sections 1-4) and "what do we do about it" (sections 6 onward).

6. Resolving penetration: positional correction

Left alone, two overlapping bodies would just sit stuck inside each other. Positional correction directly moves the two bodies apart along the contact normal, enough to remove the penetration. Which body moves how much follows the same invMass logic from section 1: a body with more inverse mass (a lighter, easier-to-push object) gets pushed further; a static body (invMass = 0) never moves at all, and the other body absorbs the entire correction:

#include <iostream>

struct Vec2 { float x, y; };
struct RigidBody { Vec2 position; float invMass; };
struct Contact { Vec2 point; Vec2 normal; float penetration; };

Vec2 add(Vec2 a, Vec2 b) { return { a.x + b.x, a.y + b.y }; }
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }

void correctPenetration(RigidBody& a, RigidBody& b, Contact c) {
    float totalInvMass = a.invMass + b.invMass;
    if (totalInvMass <= 0.0f) return;   // both static -- nothing can move

    float shareA = a.invMass / totalInvMass;
    float shareB = b.invMass / totalInvMass;

    Vec2 correction = scale(c.normal, c.penetration);
    a.position = sub(a.position, scale(correction, shareA));
    b.position = add(b.position, scale(correction, shareB));
}

int main() {
    RigidBody a = { {0.0f, 0.0f}, 0.5f };   // mass 2 -> invMass 0.5
    RigidBody b = { {1.7f, 0.0f}, 1.0f };   // mass 1 -> invMass 1.0
    Contact c = { {0.85f, 0.0f}, {1.0f, 0.0f}, 0.3f };

    correctPenetration(a, b, c);

    std::cout << "a.position=(" << a.position.x << ", " << a.position.y << ")\n";
    std::cout << "b.position=(" << b.position.x << ", " << b.position.y << ")\n";
}

Output:

a.position=(-0.1, 0)
b.position=(1.9, 0)

Body A (twice as heavy, half the inverse mass) only moves 0.1 units left; body B (lighter, full inverse mass) moves 0.2 units right — together they cover the full 0.3 penetration, split in proportion to how "easy" each one is to push. Check the new distance between them: 1.9 - (-0.1) = 2.0, exactly the sum of their radii — the overlap is completely gone.

Common mistake Correcting 100% of the penetration, every single step, with no allowance at all. It looks correct in isolation (this demo does exactly that, and it is fine for one contact), but real scenes have many contacts touching the same bodies at once, and section 9 shows that fixing one contact fully can reintroduce penetration at another. Real engines apply only a fraction of the correction per step (commonly 20-80%) and allow a tiny bit of permitted overlap called slop (a few thousandths of a unit), fixing the rest over the following steps. Correcting too aggressively in one shot is a common source of stacks that visibly "pop" or jitter instead of settling smoothly.

Baumgarte: push out through the velocity solver instead

Editing positions directly, as above, works but fights the velocity solver coming up in section 7 — you move a body one way, then the impulse step moves it another. Most engines instead fold penetration correction into the velocity solve using Baumgarte stabilization: turn the leftover overlap into a small extra separating velocity, a bias, and let the normal impulse deliver it. The bias is (beta / dt) * (penetration - slop), where beta is a small fraction (typically 0.1 to 0.2) setting how aggressively to close the gap:

#include <iostream>

int main() {
    float penetration = 0.30f;   // how deep the bodies overlap
    float slop        = 0.01f;   // permitted overlap left uncorrected
    float beta        = 0.20f;   // Baumgarte factor (fraction closed per step)
    float dt          = 1.0f / 60.0f;

    float correctable = penetration - slop;
    if (correctable < 0.0f) correctable = 0.0f;
    float biasVel = (beta / dt) * correctable;   // added to the target separating speed

    float pushThisStep = biasVel * dt;           // how far it moves them apart this step
    std::cout << "penetration      = " << penetration << "\n";
    std::cout << "bias velocity    = " << biasVel << " units/s\n";
    std::cout << "push-out this dt = " << pushThisStep << "\n";
    std::cout << "= beta*(pen-slop)= " << beta * correctable << "  (same number)\n";
}

Output:

penetration      = 0.3
bias velocity    = 3.48 units/s
push-out this dt = 0.058
= beta*(pen-slop)= 0.058  (same number)

The punchline is in the last two lines: pushing the bodies apart with a bias velocity of 3.48 for one step moves them 0.058 apart — which is exactly beta * (penetration - slop) = 0.2 * 0.29. The /dt inside the bias and the *dt of one integration step cancel, so beta is simply "what fraction of the overlap to remove per step" — the same 20%-per-step idea as the split positional correction above, just delivered as a velocity the existing impulse solver already knows how to apply. That is why it is popular: no separate position pass, one solver does both jobs.

Common mistake Setting beta too high (say 0.8, to "close the gap fast"). Because the bias is a real velocity added to the bodies, any of it left over after the shapes separate becomes actual momentum they keep — the stack gains energy and turns bouncy or jittery, exactly the pop this is supposed to prevent. Keeping beta low (0.1 to 0.2) is the usual fix; the more thorough one, used by Box2D and others, is the split impulse (or position projection): apply the bias on a separate throwaway "pseudo-velocity" that pushes shapes apart for positioning but is discarded before it can feed back into real momentum.

7. Resolving velocity: the impulse and restitution

Fixing position stops bodies from overlapping, but it says nothing about how they should be moving after the hit — a ball should bounce, not just teleport out of the floor and keep falling through it next step. That is the job of an impulse: an instantaneous change in velocity, applied directly (not gradually, the way a force applied over time works), specifically along the contact normal.

The key ingredient is relative velocity along the normal — how fast the two bodies are approaching each other, measured along the direction they are about to separate on. If that number is already zero or positive, they are separating (or just touching) on their own and no impulse is needed. If it is negative, they are approaching, and the impulse needs to cancel that approach and, depending on restitution (the bounciness coefficient, from 0 = objects stick, "totally inelastic," to 1 = a perfectly elastic bounce with no energy lost), send them apart again:

BEFORE the impulse AFTER the impulse A --vA--> <--vB-- B A <--vA'-- --vB'--> B (approaching along normal) (separating along normal, scaled by restitution e) restitution e = 0 -> they stick together, no bounce restitution e = 1 -> perfectly elastic, bounces back just as fast

The impulse magnitude formula (a standard result, derived from requiring momentum to be conserved and the post-collision separating speed to equal restitution * approaching speed) is:

j = -(1 + restitution) * velocityAlongNormal / (invMassA + invMassB) impulse vector = j * normal velocityA -= impulse * invMassA velocityB += impulse * invMassB
#include <iostream>

struct Vec2 { float x, y; };
struct RigidBody { Vec2 velocity; float invMass; };
struct Contact { Vec2 normal; };

Vec2 add(Vec2 a, Vec2 b) { return { a.x + b.x, a.y + b.y }; }
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

float resolveVelocity(RigidBody& a, RigidBody& b, Contact c, float restitution) {
    Vec2 relVel = sub(b.velocity, a.velocity);
    float velAlongNormal = dot(relVel, c.normal);

    if (velAlongNormal >= 0.0f) return 0.0f;   // already separating -- nothing to do

    float totalInvMass = a.invMass + b.invMass;
    float j = -(1.0f + restitution) * velAlongNormal / totalInvMass;

    Vec2 impulse = scale(c.normal, j);
    a.velocity = sub(a.velocity, scale(impulse, a.invMass));
    b.velocity = add(b.velocity, scale(impulse, b.invMass));
    return j;
}

int main() {
    RigidBody a = { {4.0f, 0.0f}, 0.5f };    // mass 2
    RigidBody b = { {-2.0f, 0.0f}, 1.0f };   // mass 1
    Contact c = { {1.0f, 0.0f} };

    float momentumBefore = (1.0f / a.invMass) * a.velocity.x + (1.0f / b.invMass) * b.velocity.x;
    float j = resolveVelocity(a, b, c, 0.5f);
    float momentumAfter = (1.0f / a.invMass) * a.velocity.x + (1.0f / b.invMass) * b.velocity.x;

    std::cout << "impulse j=" << j << "\n";
    std::cout << "a.velocity=(" << a.velocity.x << ", " << a.velocity.y << ")\n";
    std::cout << "b.velocity=(" << b.velocity.x << ", " << b.velocity.y << ")\n";
    std::cout << "momentum before=" << momentumBefore << " after=" << momentumAfter << "\n";
}

Output:

impulse j=6
a.velocity=(1, 0)
b.velocity=(4, 0)
momentum before=6 after=6

Body A (mass 2) was moving right at 4, body B (mass 1) was moving left at 2 — they were closing the gap at 6 units per second. After the impulse, A slows to 1 and B speeds up to 4: they now separate at 3 units per second, exactly half the approach speed, matching the restitution of 0.5. Momentum (mass * velocity, summed over both bodies) reads 6 both before and after — a real physical law falling out of the formula for free, not something the code enforces directly, which is a strong sign the formula is correct.

8. Friction: impulses along the surface

Section 7's impulse only touches velocity along the normal — it says nothing about sliding sideways. Friction is a second impulse, applied along the surface (the tangent direction, perpendicular to the normal), that resists that sideways sliding. Real friction follows an approximate rule called Coulomb friction: the maximum sideways force available is proportional to how hard the surfaces are being pressed together, capped by a friction coefficient (commonly written mu, the Greek letter mu). Concretely: compute the impulse that would fully cancel the sideways relative velocity, then clamp its size to mu * normalImpulse — if the full stop would need more than that, the surfaces slide (kinetic friction); if not, they grip completely:

#include <iostream>
#include <cmath>

struct Vec2 { float x, y; };
struct RigidBody { Vec2 velocity; float invMass; };
struct Contact { Vec2 normal; };

Vec2 add(Vec2 a, Vec2 b) { return { a.x + b.x, a.y + b.y }; }
Vec2 sub(Vec2 a, Vec2 b) { return { a.x - b.x, a.y - b.y }; }
Vec2 scale(Vec2 a, float s) { return { a.x * s, a.y * s }; }
float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
float length(Vec2 v) { return std::sqrt(v.x * v.x + v.y * v.y); }

void applyFriction(RigidBody& a, RigidBody& b, Contact c, float normalImpulse, float friction) {
    Vec2 relVel = sub(b.velocity, a.velocity);

    // build the tangent: the part of relVel that is NOT along the normal
    float velAlongNormal = dot(relVel, c.normal);
    Vec2 tangentRaw = sub(relVel, scale(c.normal, velAlongNormal));
    float tangentLen = length(tangentRaw);
    if (tangentLen < 0.0001f) return;   // no sideways motion to resist
    Vec2 tangent = scale(tangentRaw, 1.0f / tangentLen);

    float totalInvMass = a.invMass + b.invMass;
    float velAlongTangent = dot(relVel, tangent);
    float jt = -velAlongTangent / totalInvMass;   // impulse to fully stop sliding

    float maxFriction = friction * normalImpulse;   // Coulomb's clamp
    if (jt > maxFriction) jt = maxFriction;
    if (jt < -maxFriction) jt = -maxFriction;

    Vec2 frictionImpulse = scale(tangent, jt);
    a.velocity = sub(a.velocity, scale(frictionImpulse, a.invMass));
    b.velocity = add(b.velocity, scale(frictionImpulse, b.invMass));
}

int main() {
    RigidBody floorBody = { {0.0f, 0.0f}, 0.0f };    // static
    RigidBody box       = { {3.0f, -2.0f}, 1.0f };   // sliding in and down
    Contact c = { {0.0f, 1.0f} };                    // normal points up

    // step 1: resolve the normal impulse (section 7's formula, restitution 0.2)
    Vec2 relVel = sub(box.velocity, floorBody.velocity);
    float velAlongNormal = dot(relVel, c.normal);
    float totalInvMass = floorBody.invMass + box.invMass;
    float j = -(1.0f + 0.2f) * velAlongNormal / totalInvMass;
    box.velocity = add(box.velocity, scale(c.normal, j * box.invMass));
    std::cout << "after normal impulse: box.velocity=(" << box.velocity.x << ", " << box.velocity.y << ")\n";

    // step 2: friction, clamped by mu = 0.5
    applyFriction(floorBody, box, c, j, 0.5f);
    std::cout << "after friction:       box.velocity=(" << box.velocity.x << ", " << box.velocity.y << ")\n";
}

Output:

after normal impulse: box.velocity=(3, 0.4)
after friction:       box.velocity=(1.8, 0.4)

The box hit the floor moving right at 3 and down at 2. The normal impulse (restitution 0.2) kills the downward motion and gives a small upward bounce of 0.4, but leaves the full sideways speed of 3 untouched. Friction then tries to fully cancel that sideways speed — that would need an impulse of magnitude 3 — but the Coulomb clamp only allows 0.5 * 2.4 = 1.2, so the box keeps sliding, just slower: 3 drops to 1.8. If the friction coefficient had been high enough to cover the full 3, the box would have stopped sliding completely in one step instead — the clamp is exactly the boundary between "grips" and "slides."

Tip Real engines usually store a separate staticFriction (resisting the start of sliding, from rest) and dynamicFriction (resisting sliding already in progress), because in reality it typically takes more force to start something sliding than to keep it sliding. The simplified single-mu version above is the same core idea with that distinction dropped for clarity.

9. Stacking and why solvers iterate

One contact resolves cleanly in a single pass, as sections 6 and 7 just showed. Real scenes rarely have just one contact — a crate resting on the floor with another crate resting on top of it has two contacts sharing one body (the middle crate), and fixing one can quietly break the other. Watch it happen: a static floor, box2 resting on it (sunk in by 0.3), box3 resting on box2 (also sunk in by 0.3), each box exactly 1 unit tall:

#include <iostream>

struct Box { float bottom; float invMass; };   // 1D vertical stack; height is always 1

void resolveContact(Box& lower, Box& upper) {
    float lowerTop = lower.bottom + 1.0f;
    float penetration = lowerTop - upper.bottom;
    if (penetration <= 0.0f) return;

    float totalInv = lower.invMass + upper.invMass;
    if (totalInv <= 0.0f) return;

    float shareLower = lower.invMass / totalInv;
    float shareUpper = upper.invMass / totalInv;
    lower.bottom -= penetration * shareLower;
    upper.bottom += penetration * shareUpper;
}

int main() {
    Box floorBox = { 0.0f, 0.0f };   // static: top always at 1.0
    Box box2     = { 0.7f, 1.0f };   // sunk 0.3 into the floor
    Box box3     = { 1.4f, 1.0f };   // sunk 0.3 into box2

    for (int iter = 1; iter <= 3; iter++) {
        resolveContact(floorBox, box2);   // contact A: floor - box2
        resolveContact(box2, box3);       // contact B: box2 - box3

        float penA = (floorBox.bottom + 1.0f) - box2.bottom;
        float penB = (box2.bottom + 1.0f) - box3.bottom;
        std::cout << "iteration " << iter << ": penetrationA=" << penA
                   << " penetrationB=" << penB << "\n";
    }
}

Output:

iteration 1: penetrationA=0.3 penetrationB=0
iteration 2: penetrationA=0.15 penetrationB=0
iteration 3: penetrationA=0.075 penetrationB=0

Trace what happens in iteration 1: fixing contact A (floor-box2) pushes box2 fully up by 0.3, which shoves box2 deeper into box3 — the box2-box3 penetration jumps from 0.3 to 0.6. Fixing contact B then splits that 0.6 gap 50/50 between box2 and box3, which pulls box2 back down by 0.3 — right back to where it started, re-opening contact A. After one full pass, contact A is exactly as broken as when the step began. Only by processing both contacts again (iteration 2) does contact A improve — the penetration halves, to 0.15, then halves again to 0.075 in iteration 3, and would keep halving forever without ever quite reaching zero in a finite number of steps.

iteration 1: floor--[box2] [box2]--box3 still 0.3 gap push box2 up 0.3 pulls box2 back down at floor-box2 (fixes A, breaks B) 0.3 to fix B (right back where it started) iteration 2: push box2 up 0.15 pulls box2 back 0.075 0.15 gap (fixes A again) (fixes B again) shrinking each pass gets the whole stack a little closer to a state where EVERY contact is satisfied AT ONCE -- which is exactly why solvers run several passes ("iterations") instead of stopping after one

This is why physics engines are called iterative solvers: a single pass through every contact (this pattern is called sequential impulse when applied to velocities, and is a form of the Gauss-Seidel method mathematically) rarely satisfies every contact simultaneously the moment two or more contacts share a body. Running several passes — a handful of iterations, commonly somewhere around 4 to 10 — converges the whole system toward a state that is close enough to "every contact satisfied at once" to look and feel solid. More iterations mean a stiffer, more accurate stack at more CPU cost per step; fewer iterations are cheaper but let stacks visibly sink, jitter, or feel slightly springy. This is a real, tunable trade-off, not a bug to be fixed once and forgotten — which is exactly why engines like Unity expose the iteration count as a setting rather than hard-coding it.

10. Continuous collision detection: stopping tunneling

Every test so far is discrete: it looks at where a body is at the start and end of a step and checks those two snapshots for overlap. That works fine for anything moving slower than its own size per step. It fails completely for anything fast and thin — a bullet, a thrown knife, a speeding car versus a chain-link fence — because the object can be entirely on one side of a thin wall at the start of a step and entirely on the other side at the end, having passed straight through the middle without either snapshot ever catching it mid-flight. This failure is called tunneling.

#include <iostream>

struct Wall { float xStart, xEnd; };

bool discreteHitsWall(float x, Wall w) {
    return x >= w.xStart && x <= w.xEnd;
}

int main() {
    Wall wall = { 5.0f, 5.1f };   // a thin wall, only 0.1 units thick
    float x0 = 4.5f;              // position at the START of this step
    float speed = 80.0f;
    float dt = 0.02f;
    float x1 = x0 + speed * dt;   // position at the END of this step

    std::cout << "x0=" << x0 << " x1=" << x1 << "\n";
    std::cout << "discrete check at x0: " << (discreteHitsWall(x0, wall) ? "HIT" : "clear") << "\n";
    std::cout << "discrete check at x1: " << (discreteHitsWall(x1, wall) ? "HIT" : "clear") << "\n";
}

Output:

x0=4.5 x1=6.1
discrete check at x0: clear
discrete check at x1: clear

The ball travels 1.6 units this step (80 * 0.02) but the wall is only 0.1 units thick — the ball leaps clean over the wall's entire span between one sample and the next, and both discrete checks report "clear." The ball tunneled straight through and nothing noticed.

discrete check ONLY looks at x0 and x1 -- misses the wall entirely: x0=4.5 WALL x1=6.1 o -------------------- [||] ------------------------> o 5.0 5.1 (checked here, clear) (checked here, clear -- but it passed straight through!)

Continuous collision detection (CCD) fixes this by testing the entire path the object swept through this step, not just its two endpoints — a swept test. The simplest version treats the movement as a line segment from the start position to the end position and finds exactly where that segment first crosses the wall:

#include <iostream>

struct Wall { float xStart, xEnd; };

bool sweptHitsWall(float x0, float x1, Wall w, float& hitT, float& hitX) {
    if (x0 >= w.xStart) return false;   // already at/past the wall's near face
    if (x1 < w.xStart) return false;    // never reached the wall at all

    hitT = (w.xStart - x0) / (x1 - x0);
    hitX = x0 + hitT * (x1 - x0);
    return true;
}

int main() {
    Wall wall = { 5.0f, 5.1f };
    float x0 = 4.5f, x1 = 6.1f;

    float hitT, hitX;
    if (sweptHitsWall(x0, x1, wall, hitT, hitX)) {
        std::cout << "swept test: HIT at t=" << hitT << ", x=" << hitX << "\n";
    } else {
        std::cout << "swept test: clear\n";
    }
}

Output:

swept test: HIT at t=0.3125, x=5

The swept test catches exactly what the two discrete snapshots missed: the ball crosses the wall's near face 31.25% of the way through this step's movement, at position x = 5.0. A real engine responds by stopping the object right there (or resolving a full collision at that point) instead of letting it continue to x = 6.1. Real CCD implementations go further than this simple line test — conservative advancement expands the swept segment by the moving shape's own radius so a fast sphere, not just a fast point, is caught correctly, and some engines instead use speculative contacts, which predict an upcoming collision one step early and start resolving it slightly before the shapes actually touch. The core idea stays the same either way: look at the whole path swept this step, not just its two ends.

Common mistake Turning on CCD for every single object "to be safe." Swept tests cost noticeably more than a discrete check, and most objects in a scene — a resting crate, a slow-walking character — never move fast enough relative to their own size to tunnel. Engines expect you to enable CCD selectively, only for objects genuinely at risk: bullets, thrown weapons, anything fast and small next to anything thin.

11. Resting contacts: jitter, restitution, and sleeping

A box dropped on the floor should, after a bounce or two, just sit there — dead still, costing nothing. Getting a body to truly come to rest is its own small problem, because the naive loop from the sections above never quite lets it. Gravity pulls the resting box down a hair every step, the contact catches it, and section 7's restitution faithfully bounces that hair back up. The result is a body that never stops twitching:

#include <iostream>

// one step of a ball above a floor at y=0: semi-implicit Euler, then a bounce.
// `threshold`: approach speeds slower than this get restitution 0 (no bounce).
void step(float& y, float& v, float g, float dt, float e, float threshold) {
    v += g * dt;
    y += v * dt;
    if (y < 0.0f) {                 // hit the floor this step
        y = 0.0f;
        if (v < 0.0f) {
            float useE = (-v < threshold) ? 0.0f : e;   // slow contact -> no bounce
            v = -useE * v;
        }
    }
}

void run(const char* label, float threshold) {
    float y = 0.0f, v = 0.0f, g = -10.0f, dt = 1.0f / 60.0f, e = 0.5f;
    std::cout << label << "\n";
    for (int i = 1; i <= 6; i++) {
        step(y, v, g, dt, e, threshold);
        std::cout << "  step " << i << ": y=" << y << "  v=" << v << "\n";
    }
}

int main() {
    run("no threshold (restitution always on) -> never settles:", 0.0f);
    run("with threshold 1.0 -> settles to rest:", 1.0f);
}

Output:

no threshold (restitution always on) -> never settles:
  step 1: y=0  v=0.0833333
  step 2: y=0  v=0.0416667
  step 3: y=0  v=0.0625
  step 4: y=0  v=0.0520833
  step 5: y=0  v=0.0572917
  step 6: y=0  v=0.0546875
with threshold 1.0 -> settles to rest:
  step 1: y=0  v=0
  step 2: y=0  v=0
  step 3: y=0  v=0
  step 4: y=0  v=0
  step 5: y=0  v=0
  step 6: y=0  v=0

With restitution always on (top), the velocity never reaches zero — it locks into a permanent buzz around +0.05, because every step gravity feeds in a little downward speed and restitution kicks it straight back up. In a real scene that residual upward velocity lifts the box a hair off the floor each step: the visible resting jitter of a stack that will not hold still. The fix (bottom) is a restitution threshold (also called a bounce threshold): if the approach speed is below some small value, treat restitution as zero, so a slow contact just stops instead of bouncing. One line, and the box settles on the very first step and stays put. Every engine does this; Unity exposes it as Physics.bounceThreshold.

Sleeping: switching a settled body off

The threshold gets a body to rest, but a resting body still costs CPU: every step it is integrated, tested for collisions, and run through the solver, only to end up exactly where it was. In a scene with hundreds of settled objects — a warehouse of crates, a pile of rubble — that is enormous waste, and worse, the tiny numerical noise left in the solver keeps nudging those bodies so they never sit perfectly still. The answer is sleeping: once a body's speed has stayed below a threshold for long enough, mark it asleep and stop simulating it entirely until something touches it and wakes it up.

#include <iostream>
#include <cmath>

struct Body {
    float x, v;
    float idleTime;   // how long we have been below the sleep threshold
    bool  asleep;
};

int main() {
    Body b = {0.0f, 5.0f, 0.0f, false};
    float dt = 1.0f / 60.0f;
    float sleepSpeed = 0.10f;   // below this counts as "still"
    float sleepDelay = 0.05f;   // must stay still this long to fall asleep

    for (int i = 1; i <= 14; i++) {
        if (!b.asleep) {
            b.v *= 0.6f;                 // friction bleeds speed off each step
            b.x += b.v * dt;
            if (std::fabs(b.v) < sleepSpeed) b.idleTime += dt;
            else                             b.idleTime = 0.0f;
            if (b.idleTime >= sleepDelay) b.asleep = true;   // go to sleep
        }
        // an asleep body skips integration entirely -- zero CPU until woken
        std::cout << "step " << i << ": x=" << b.x << "  v=" << b.v
                  << (b.asleep ? "  [ASLEEP]" : "") << "\n";
    }
}

Output:

step 1: x=0.05  v=3
step 2: x=0.08  v=1.8
step 3: x=0.098  v=1.08
step 4: x=0.1088  v=0.648
step 5: x=0.11528  v=0.3888
step 6: x=0.119168  v=0.23328
step 7: x=0.121501  v=0.139968
step 8: x=0.1229  v=0.0839808
step 9: x=0.12374  v=0.0503885
step 10: x=0.124244  v=0.0302331  [ASLEEP]
step 11: x=0.124244  v=0.0302331  [ASLEEP]
step 12: x=0.124244  v=0.0302331  [ASLEEP]
step 13: x=0.124244  v=0.0302331  [ASLEEP]
step 14: x=0.124244  v=0.0302331  [ASLEEP]

The body slows under friction until, at step 10, its speed has been under 0.1 for the required 0.05 seconds; it falls asleep, and from then on its position is frozen and the loop does no work on it at all. A real engine goes one step further and sleeps islands together — a stack of boxes all resting on each other must sleep and wake as a group, since waking the bottom one has to wake the ones above it. This is also a famous source of "bugs that are not bugs": a crate sitting on a pressure plate that a designer expects to trigger, or an object that should react to a slow nudge, does nothing — because it is asleep. In Unity the knobs are Rigidbody.sleepThreshold, the query rb.IsSleeping(), and the escape hatch rb.WakeUp() to force a body awake when your own code has changed something the physics engine cannot see.

12. Using the engine's physics: Rigidbody and Collider in Unity

Every idea in sections 1 through 11 is exactly what Unity's built-in physics (backed by PhysX) is doing for you, every fixed step, without you writing a single line of broad phase, narrow phase, or impulse-resolution code. Recognizing the concept inside the component is the whole point of this section — you will spend far more time configuring Rigidbody and Collider than writing your own solver.

using UnityEngine;

public class BouncyBall : MonoBehaviour
{
    void Start()
    {
        Rigidbody rb = GetComponent<Rigidbody>();

        rb.mass = 2f;                  // section 1's RigidBody.mass
        rb.linearVelocity = new Vector3(3f, 0f, 0f);   // section 1's RigidBody.velocity
        rb.useGravity = true;          // section 2's integrate(), running every FixedUpdate

        // section 10: switch on CCD only for objects at real risk of tunneling
        rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic;
    }
}

Rigidbody is section 1's RigidBody struct, field for field: mass, linearVelocity, angularVelocity, position/rotation (the latter stored as a quaternion, exactly as the rotation chapter described). Every FixedUpdate tick — the same fixed timestep from the game loop chapter — Unity runs section 2's semi-implicit Euler step on it internally, using Time.fixedDeltaTime, for exactly the stability and determinism reasons section 2 explained.

A Collider (BoxCollider, SphereCollider, CapsuleCollider, MeshCollider for convex meshes) is the shape PhysX's broad phase and narrow phase actually operate on — PhysX runs its own broad phase over every collider's world-space bounds (sections 3's job) and its own narrow phase (SAT-like tests for simple shapes, GJK-family algorithms for convex meshes, exactly section 4) to find contacts, entirely off-screen from your code:

using UnityEngine;

public class ContactLogger : MonoBehaviour
{
    void OnCollisionEnter(Collision collision)
    {
        // section 5's Contact struct, handed to you directly by Unity
        foreach (ContactPoint contact in collision.contacts)
        {
            Debug.Log("point=" + contact.point + " normal=" + contact.normal
                     + " separation=" + contact.separation);   // separation < 0 means penetrating
        }
    }
}

Every field from section 5's Contact struct is right there in ContactPoint: point, normal, and separation (Unity's name for penetration depth, with the sign flipped — negative means overlapping). Restitution and friction — sections 7 and 8 — live on a Physic Material asset assigned to the collider, with a combine mode (average, minimum, maximum, or multiply) controlling how two different bodies' materials blend when they touch:

using UnityEngine;

public class BouncyMaterialSetup : MonoBehaviour
{
    void Start()
    {
        PhysicsMaterial mat = new PhysicsMaterial();
        mat.bounciness = 0.5f;                              // section 7's restitution
        mat.dynamicFriction = 0.5f;                          // section 8's friction (sliding)
        mat.staticFriction = 0.6f;                           // section 8's friction (starting from rest)
        mat.frictionCombine = PhysicsMaterialCombine.Average;

        GetComponent<Collider>().material = mat;
    }
}

Section 9's iteration count is a real, exposed setting too — Project Settings > Physics > Solver Iterations (position iterations) and Solver Velocity Iterations control exactly how many passes PhysX's internal solver runs per step, the same convergence-through-repetition idea from section 9's stacked-box trace, just running on every contact in the scene at once rather than two contacts by hand.

Tip You will almost never write your own broad phase, narrow phase, or impulse solver in a real Unity project — PhysX is fast, well-tested, and already there. What sections 1 through 11 buy you is the ability to read the symptoms: a stack that jitters points at solver iterations (section 9); an object passing through a thin wall points at collision detection mode (section 10); a ball that will not bounce right points at the Physic Material's restitution (section 7) or its combine mode. Debugging Unity physics without this chapter is guesswork; debugging it with this chapter is reading a checklist.

13. Glossary

14. Exercises

Exercise 1 Three circles: E at (2, 2) with radius 1, F at (3.2, 2) with radius 1, G at (10, 10) with radius 1. Compute each circle's AABB, determine which pair(s) broad phase would flag as candidates, then run the exact narrow-phase test on that candidate pair and compute its contact (point, normal, penetration depth).
Show answer
AABBs:
E: min=(1, 1)   max=(3, 3)
F: min=(2.2, 1) max=(4.2, 3)
G: min=(9, 9)   max=(11, 11)

E-F: overlap (x: 1<=4.2 and 3>=2.2; y: identical range) -> CANDIDATE
E-G: no overlap (E.max.x=3 < G.min.x=9)
F-G: no overlap (F.max.x=4.2 < G.min.x=9)

narrow phase E-F:
distance = 1.2, sum of radii = 2  ->  1.2 < 2, colliding
penetration = 2 - 1.2 = 0.8
normal = (1, 0)
point on E = (3, 2), point on F = (2.2, 2), contact = midpoint = (2.6, 2)

Only E-F share overlapping AABBs, so it is the only pair narrow phase ever needs to test — G never gets an exact test run against anything, which is the entire point of broad phase. The exact test confirms a real collision with a fairly large penetration (0.8, most of each circle's own radius), a normal pointing straight from E toward F along the x-axis (since both circles sit at the same y), and a contact point at the midpoint of the overlap.

Exercise 2 Body A has mass 3 and velocity (2, 0). Body B has mass 1 and velocity (-4, 0). They collide with contact normal (1, 0) (pointing from A toward B) and restitution 1 (perfectly elastic). Compute the impulse magnitude j and both bodies' velocities after the impulse, then verify momentum is conserved.
Show answer
invMassA = 1/3, invMassB = 1
relVel = velB - velA = (-4,0) - (2,0) = (-6, 0)
velocityAlongNormal = -6

j = -(1 + 1) * (-6) / (1/3 + 1) = 12 / (4/3) = 9

impulse = (9, 0)
velA_new = (2,0) - (9,0)*(1/3) = (2-3, 0) = (-1, 0)
velB_new = (-4,0) + (9,0)*1     = (-4+9, 0) = (5, 0)

momentum before = 3*2 + 1*(-4) = 6 - 4 = 2
momentum after  = 3*(-1) + 1*5 = -3 + 5 = 2   -- matches

With restitution 1, the bodies separate at exactly the same speed they approached: they were closing at 6 units per second, and after the impulse they separate at velB_new - velA_new = 5 - (-1) = 6 units per second — the defining property of a perfectly elastic collision. Momentum reading exactly 2 both before and after is the same free correctness check section 7 used, and it holds here too.

Exercise 3 An object starts a physics step at x = 9.0, moving at 150 units per second, with a fixed timestep of 0.02 seconds. A thin wall spans x = 10.0 to x = 10.2. Would a discrete (start/end only) collision check catch a hit this step? If not, use the swept-test formula from section 10 to find exactly where along the movement the object first reaches the wall.
Show answer
travel this step = 150 * 0.02 = 3.0
x0 = 9.0, x1 = 9.0 + 3.0 = 12.0

discrete check at x0=9.0: 10.0 <= 9.0 <= 10.2 ? NO
discrete check at x1=12.0: 10.0 <= 12.0 <= 10.2 ? NO
-> tunneling: the discrete check misses the wall completely

swept test:
hitT = (10.0 - 9.0) / (12.0 - 9.0) = 1.0 / 3.0 = 0.3333
hitX = 9.0 + 0.3333 * 3.0 = 10.0

The object's per-step travel (3.0 units) is fifteen times the wall's thickness (0.2 units), so it is easy for both discrete snapshots to land cleanly on either side of the wall without ever overlapping it — exactly the tunneling failure mode from section 10. The swept test finds the object actually reaches the wall's near face one third of the way through the step's movement, at x = 10.0; a real engine would stop or resolve the collision there instead of letting the object arrive at x = 12.0 as if nothing were in the way.

Exercise 4 A circle of radius 1.5 is centered at (6, 4). An axis-aligned box spans (2, 1) to (5, 3). Using the closest-point test from section 4, find the point on the box nearest the circle's center, the distance to it, whether the two collide, and if so the contact normal and penetration depth.
Show answer
closest point = clamp center (6,4) into [2,5] x [1,3]
              = (5, 3)   -- a corner of the box

d    = center - closest = (6-5, 4-3) = (1, 1)
dist = sqrt(1*1 + 1*1)  = sqrt(2) = 1.41421
1.41421 < 1.5  ->  COLLIDING

penetration = 1.5 - 1.41421 = 0.08579
normal      = d / dist = (0.7071, 0.7071)

The circle's center clamps to (5, 3), a corner of the box, so the nearest feature is a single point, not a face — and that is exactly why the normal comes out diagonal, (0.7071, 0.7071), rather than axis-aligned. The circle overlaps that corner by a shallow 0.086, and the push-out direction points cleanly away from the corner along the 45-degree line. This corner case is the whole reason the closest-point test beats a naive "which side is it on" check: it handles faces and corners with the same single clamp.

That is the full pipeline a physics engine runs every fixed step: integrate every body's position and rotation forward with semi-implicit Euler, narrow a world full of possible pairs down to a handful of real candidates with broad phase, confirm and measure each one exactly with narrow phase, turn a confirmed overlap into a contact, and then resolve that contact twice over — once to fix position (pushing penetration out) and once to fix velocity (impulses, restitution, and friction) — repeating the whole resolve step a few times so that stacks and piles of contacts settle into something consistent instead of fighting each other, and finally letting any body that has come fully to rest sleep so a quiet scene costs almost nothing. Continuous collision detection is the one addition needed to stop that pipeline from missing anything fast and thin. Unity's Rigidbody and Collider run every one of these steps for you, which is exactly why understanding them here turns "physics feels broken" into a short, specific checklist instead of a guess.

← Back to all chapters