Lesson 11: SAT & Rotational Collisions
Circles bounce off each other in a straight line, but a crate that lands on its corner should tip over and a box sliding down a ramp should feel friction. In this lesson you detect collisions between rotating polygons with the Separating Axis Theorem and resolve them with impulses that change both how fast bodies move and how fast they spin.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Test two rotated convex polygons for overlap with the Separating Axis Theorem and find the depth and direction to push them apart.
- Compute a polygon's moment of inertia and the velocity of any point on a spinning body.
- Apply a contact impulse that includes rotation, with restitution and a correctly signed, clamped friction impulse.
- Resolve a circle against a polygon and split the push-out between bodies by inverse mass.
- Explain the difference between sub-stepping and true continuous collision detection, and compute a swept time of impact for two circles.
Project: Tumbling Crates, a scene where rotating boxes fall, tip over, slide on a ramp and pile up.
In This Lesson
๐ From Circles to Rotating Polygons
In Impulse Collisions (Circles) you resolved circle hits along the line between the two centers. That line is always the contact normal for circles, and circles look the same at every angle, so rotation never mattered. Boxes, planks and crates are different: where they touch depends on which way they are turned, and a hit away from the center makes them spin.
We describe a polygon by its corners in local space, centered on (0, 0), plus a position and an angle. Every frame, the corners are rotated and moved into world space:
def box_points(w, h):
return [Vector2(-w / 2, -h / 2), Vector2(w / 2, -h / 2), Vector2(w / 2, h / 2), Vector2(-w / 2, h / 2)]
def world_points(self):
return [self.pos + p.rotate_rad(self.angle) for p in self.local]
Vector2.rotate_rad rotates by an angle in radians. On pygame's screen, where y grows downward, a positive angle turns things clockwise. You don't need to fight that: as long as every formula in this lesson uses the same convention, the physics comes out right, and it does.
All the shapes here are convex: no dents, so a straight line between any two points inside stays inside. SAT only works on convex shapes. A concave shape (an L-shaped wall) is split into convex pieces first.
๐ฆ The Separating Axis Theorem
Shine a flashlight on two objects and look at their shadows on the wall. If you can find any direction where the shadows don't overlap, the objects aren't touching. For convex shapes the reverse is also true: if the shadows overlap from every direction, the shapes overlap. That is the Separating Axis Theorem (SAT).
"Every direction" sounds infinite, but for convex polygons you only need to test the directions perpendicular to each edge, the edge normals of both shapes. A box has only two distinct edge directions, so two boxes need at most four tests. To take a shadow, project every corner onto the axis with a dot product and keep the smallest and largest values:
def edge_normals(points):
normals = []
for i, a in enumerate(points):
edge = points[(i + 1) % len(points)] - a
if edge.length_squared() > 0:
normals.append(Vector2(-edge.y, edge.x).normalize())
return normals
def project(points, axis):
dots = [p.dot(axis) for p in points]
return min(dots), max(dots)
Move the orange box right until the label says APART, then press Next axis until you find the axis with the gap. Predict first: will it be one of the blue box's edge normals or one of the orange box's?
Here is the whole test as a complete program. It checks three pairs, including a case where the bounding boxes overlap but the shapes don't:
import math
from pygame import Vector2
def box_points(w, h, center, angle_deg=0.0):
"""World-space corners of a w x h box rotated by angle_deg around its center."""
corners = [Vector2(-w / 2, -h / 2), Vector2(w / 2, -h / 2), Vector2(w / 2, h / 2), Vector2(-w / 2, h / 2)]
return [Vector2(center) + c.rotate(angle_deg) for c in corners]
def edge_normals(points):
normals = []
for i, a in enumerate(points):
edge = points[(i + 1) % len(points)] - a
if edge.length_squared() > 0:
normals.append(Vector2(-edge.y, edge.x).normalize())
return normals
def project(points, axis):
dots = [p.dot(axis) for p in points]
return min(dots), max(dots)
def sat(pa, pb):
"""None if the convex polygons are apart, else (normal from A to B, depth)."""
best_axis, best_depth = None, math.inf
for axis in edge_normals(pa) + edge_normals(pb):
min_a, max_a = project(pa, axis)
min_b, max_b = project(pb, axis)
overlap = min(max_a, max_b) - max(min_a, min_b)
if overlap <= 0:
return None # found a gap: not touching
if overlap < best_depth:
best_axis, best_depth = axis, overlap
center_a = sum(pa, Vector2()) / len(pa)
center_b = sum(pb, Vector2()) / len(pb)
if (center_b - center_a).dot(best_axis) < 0:
best_axis = -best_axis # always A -> B
return best_axis, best_depth
a = box_points(40, 40, (0, 0))
tests = {
"side by side, 10 px overlap": box_points(40, 40, (30, 5)),
"diamond near a corner": box_points(40, 40, (45, 45), 45),
"diamond pushed closer": box_points(40, 40, (30, 30), 45),
}
for name, b in tests.items():
hit = sat(a, b)
if hit is None:
print(f"{name}: apart")
else:
normal, depth = hit
print(f"{name}: overlap {depth:.2f} px along ({normal.x + 0.0:.2f}, {normal.y + 0.0:.2f})")
The "diamond near a corner" is the important case. The diamond's bounding box overlaps the square's, so an axis-aligned rectangle test would call it a hit, but the diagonal axis from the diamond's edges shows a gap. Use bounding boxes as a cheap first filter (the lab does), then SAT for the real answer.
๐ Depth, Direction and Contact Points
When every axis overlaps, SAT gives you more than "yes". The axis with the smallest overlap is the cheapest way out: moving the shapes apart by that overlap along that axis separates them. That pair (axis, overlap) is the minimum translation vector, and it becomes the contact normal and penetration depth.
One detail causes most "my boxes explode" bugs. An edge normal can point either way along its axis, and the resolver needs to know which way is "from A toward B". The fix is one test against the line between the centers:
if (center_b - center_a).dot(best_axis) < 0:
best_axis = -best_axis # always A -> B
If you skip this, about half of all contacts get a normal that points inward, and the "push apart" impulse pulls the shapes together instead.
Where do they touch? For spin, you also need a contact point. A simple and robust choice is to use the corners that have poked inside the other shape: one corner when a crate lands on its tip, two when it lands flat. If edges cross with no corner inside, the deepest corner of B along the normal is a good stand-in. The lab applies an impulse at each of up to two corners. Production engines find contact points by clipping one polygon's edge against the other, which gives better results for deep or sliding contacts; you will use one of those engines, pymunk, in the next lesson.
โ Growth Mindset: Draw the Normals
Physics bugs are invisible in numbers and obvious in pictures. If crates jitter, sink or fly apart, you haven't failed at physics; you just can't see your data yet. Draw every contact point as a dot and every normal as a short line (the lab's D key does this). A normal pointing the wrong way, or a contact point in the middle of nowhere, jumps out immediately. Every physics programmer keeps a debug-draw switch like this.
๐ Spin: Angular Velocity and Inertia
A rigid body now has two kinds of motion: its center moves with velocity vel (px/s), and it turns with angular velocity omega (radians per second). Both integrate the same way, with dt in seconds:
body.vel += GRAVITY * dt
body.pos += body.vel * dt
body.angle += body.omega * dt
A point on a spinning body moves faster the farther it is from the center. For a point at offset r from the center, the extra velocity from the spin is ฯ ร r, which in 2D is a vector at right angles to r:
def cross(a, b):
"""2D cross product: a scalar (the z of the 3D cross)."""
return a.x * b.y - a.y * b.x
def spin_velocity(omega, r):
"""Velocity of a point at offset r on a body spinning at omega rad/s (omega x r)."""
return Vector2(-omega * r.y, omega * r.x)
point_velocity = body.vel + spin_velocity(body.omega, r)
Just as mass says how hard it is to change velocity, the moment of inertia I says how hard it is to change spin. It depends on how the mass is spread out: mass far from the center is harder to spin up. Three formulas cover the shapes in this course:
| Shape (solid, about its center) | Moment of inertia |
|---|---|
| Disc of radius r | I = ยฝ m rยฒ |
| Box w ร h | I = m (wยฒ + hยฒ) / 12 |
| Convex polygon, corners around its centroid | the triangle-fan sum in polygon_inertia below |
def polygon_inertia(points, mass):
"""Moment of inertia of a solid convex polygon about its centroid (points centered on (0, 0))."""
num = den = 0.0
for i, a in enumerate(points):
b = points[(i + 1) % len(points)]
area2 = abs(cross(a, b))
num += area2 * (a.dot(a) + a.dot(b) + b.dot(b))
den += area2
return mass * num / (6 * den)
You can check the general formula against the box formula: for a 30 ร 20 box of mass 2, both give 216.67 (the lab's tests do exactly this). Static bodies such as floors and ramps get inv_mass = inv_inertia = 0: infinite mass and infinite inertia, so no impulse can move or turn them.
๐ฅ Impulses That Spin, and Friction
The contact impulse works like the circle version, with two changes: velocities are measured at the contact point (including spin), and the impulse also changes each body's spin. With ra and rb the offsets from each center to the contact point:
def apply_impulse(a, b, impulse, ra, rb):
"""Equal and opposite impulse at the contact: changes velocity AND spin."""
a.vel -= impulse * a.inv_mass
a.omega -= cross(ra, impulse) * a.inv_inertia
b.vel += impulse * b.inv_mass
b.omega += cross(rb, impulse) * b.inv_inertia
How big should the impulse be? Big enough that the contact points stop approaching, and bounce apart by the restitution e. Solving for that gives the circle formula with extra terms in the denominator:
j = โ(1 + e) vn / (1/ma + 1/mb + (ra ร n)ยฒ / Ia + (rb ร n)ยฒ / Ib)
The two new terms are the part of the hit that goes into spinning the bodies instead of moving them. A hit straight through the center has r ร n = 0 and behaves exactly like a circle; a hit on a corner makes the body "feel lighter" at that point, because it can turn out of the way.
Friction acts along the tangent, the direction the contact points slide past each other. The impulse that would stop the sliding completely is computed the same way along the tangent, and then Coulomb's law caps it: friction can never be larger than ฮผ times the normal impulse. Without the cap, every contact acts like glue: a light, glancing touch could stop a fast slide dead, and no slope would ever be steep enough for a crate to slide down.
rel = (b.vel + spin_velocity(b.omega, rb)) - (a.vel + spin_velocity(a.omega, ra))
tangent = rel - normal * rel.dot(normal)
if tangent.length_squared() > 1e-12:
t = tangent.normalize()
rat, rbt = cross(ra, t), cross(rb, t)
kt = a.inv_mass + b.inv_mass + rat * rat * a.inv_inertia + rbt * rbt * b.inv_inertia
jt = -rel.dot(t) / kt # the impulse that would stop the sliding
mu = math.sqrt(a.friction * b.friction)
jt = max(-mu * j, min(mu * j, jt)) # Coulomb: |friction| <= mu * normal impulse
apply_impulse(a, b, t * jt, ra, rb)
Notice that friction goes through the same apply_impulse as the normal impulse, so its effect on spin has the same signs. A common bug is to hand-write the friction spin update with one sign flipped; then sliding crates spin up instead of slowing down.
Two small stabilizers finish the resolver. Very slow impacts (below about 30 px/s) get e = 0, so a resting crate doesn't bounce on every frame from the tiny speed gravity adds. And after the impulses, overlapping bodies are pushed apart by a share of the remaining depth, split by inverse mass, so a static floor never moves and a light crate moves more than a heavy one.
๐ก Why this matters
These few formulas are the core of every 2D rigid-body engine: contact normal, contact point, effective mass with the spin terms, and a clamped friction impulse. Once you have written them, engine documentation ("restitution", "friction coefficient", "moment of inertia", "iterations") stops being jargon and becomes knobs you understand.
โก Circles, Polygons and Fast Objects
Circle against polygon. A ball rolling down a ramp needs a third test. Find the point on the polygon's outline closest to the circle's center. If the center is outside the polygon and that point is closer than the radius, they overlap, and the normal points from the center toward the closest point. If the center is already inside, the push must go the other way. The push-out is split by inverse mass, as with polygons:
from pygame import Vector2
def closest_on_segment(p, a, b):
ab = b - a
t = max(0.0, min(1.0, (p - a).dot(ab) / ab.dot(ab)))
return a + ab * t
def inside(point, points):
"""True if point is inside the convex polygon (either winding)."""
signs = [(points[(i + 1) % len(points)] - a).cross(point - a) for i, a in enumerate(points)]
return all(s >= 0 for s in signs) or all(s <= 0 for s in signs)
def circle_polygon(center, radius, points):
"""None if apart, else (normal from the circle into the polygon, depth)."""
closest = min((closest_on_segment(center, points[i], points[(i + 1) % len(points)])
for i in range(len(points))), key=lambda q: center.distance_squared_to(q))
offset = closest - center
dist = offset.length()
if dist < 1e-9: # center exactly on the outline: push away from the middle
middle = sum(points, Vector2()) / len(points)
return (middle - center).normalize(), radius
if inside(center, points): # center already inside: push out the other way
return -offset / dist, radius + dist
if dist >= radius:
return None
return offset / dist, radius - dist
def separate(pos_a, inv_a, pos_b, inv_b, normal, depth):
"""Split the push by inverse mass: heavy bodies move less, static ones (0) never move."""
total = inv_a + inv_b
if total == 0:
return pos_a, pos_b
return pos_a - normal * (depth * inv_a / total), pos_b + normal * (depth * inv_b / total)
ramp = [Vector2(0, 100), Vector2(200, 40), Vector2(200, 100)] # a static triangle
ball = Vector2(100, 62)
normal, depth = circle_polygon(ball, 12, ramp)
print(f"normal ({normal.x:.2f}, {normal.y:.2f}), depth {depth:.2f} px")
ball, _ = separate(ball, 1 / 2.0, Vector2(), 0.0, normal, depth) # ball mass 2, ramp static
hit = circle_polygon(ball, 12, ramp)
print("after the push:", "apart" if hit is None else f"depth {hit[1]:.3f} px")
(Vector2.cross gives the same 2D cross product as our cross() helper.)
Tunneling. All of these tests look at where bodies are, once per step. A small, fast body can be on one side of a thin wall in one step and on the other side in the next, and no test ever sees the overlap. There are two fixes, and they are easy to confuse:
- Sub-stepping runs several smaller steps per frame (or several checks along the path). It makes tunneling less likely and costs a proportional amount of CPU, but a fast enough object still slips through. It is not continuous collision detection, even though it is sometimes labeled that way.
- Continuous collision detection (CCD) solves for the exact moment the paths first touch. For two circles moving in straight lines this is a quadratic equation, so it can't miss:
import math
from pygame import Vector2
def circle_toi(p1, v1, r1, p2, v2, r2, dt):
"""Time in [0, dt] when two moving circles first touch, or None.
Solve |dp + dv * t| = r1 + r2 for the smallest t: a quadratic in t.
"""
dp, dv = p2 - p1, v2 - v1
radius = r1 + r2
c = dp.dot(dp) - radius * radius
if c <= 0:
return 0.0 # already touching
a = dv.dot(dv)
b = 2 * dp.dot(dv)
disc = b * b - 4 * a * c
if a == 0 or disc < 0:
return None # not moving relative, or paths miss
t = (-b - math.sqrt(disc)) / (2 * a) # the earlier root = first contact
return t if 0 <= t <= dt else None
bullet, speed = Vector2(0, 0), Vector2(3000, 0) # 3000 px/s, radius 2
target = Vector2(60, 0) # radius 5, not moving
dt = 1 / 30
# Discrete test: check only where the bullet ends up each frame.
end = bullet + speed * dt
print(f"discrete: bullet jumps from x=0 to x={end.x:.0f}; overlaps at the end? {end.distance_to(target) < 7}")
t = circle_toi(bullet, speed, 2, target, Vector2(), 5, dt)
print(f"swept: first contact at t = {t * 1000:.2f} ms, bullet at x = {(bullet + speed * t).x:.1f}")
With the time of impact t, you move both bodies to that moment, resolve the collision there, and spend the remaining dt โ t moving with the new velocities. Swept tests for rotating polygons are much harder, which is why engines usually reserve CCD for small, fast bodies such as bullets, and treat them as circles or rays.
โ Growth Mindset: This Is the Hard Part
Angular impulses are the most math-heavy topic in this course, and it is completely normal to need two or three passes. You don't have to derive every formula to use it well. Aim first to explain what each term does (the spin terms make a corner hit feel lighter; the clamp stops friction from being stronger than the push), then check that the lab's tests pass. Understanding deepens with each crate you watch tip over.
๐๏ธ Practice Exercise: Tumbling Crates
Objective: finish a rigid-body scene in which rotating crates fall, tip over onto a flat side, grip a ramp with friction and pile up without sinking.
Time: about 40 minutes. Starter file: tumbling_crates_starter.py (your instructor has it). Gravity, the fixed-step loop, drawing and the resolver's outline are in place; right now nothing collides, so every crate falls through the floor. Its numbered comments match the steps below.
- Run the starter and watch the crates fall out of the window. (โ 2 min)
- Write the SAT loop in
sat(): project both polygons onto every edge normal, returnNoneon a gap, and keep the smallest overlap. Crates now land, but some are shot away. (โ 10 min) - Flip the normal so it always points from A to B. Landings calm down. (โ 4 min)
- Add the spin terms to the effective mass
k. (โ 5 min) - Clamp the friction impulse with Coulomb's law. (โ 4 min)
- Finish
apply_impulsewith the two spin updates. Crates now tip over when they land on a corner. Press D to toggle the contact points and normals. (โ 8 min) - Click to drop more crates on the ramp and on the pile, and watch them settle. (โ 5 min)
You are done when:
- a crate that lands on a corner tips over onto a flat side and stops;
- crates on the ramp stay put (friction 0.6 is enough for a 22ยฐ slope);
- a pile of a dozen crates doesn't sink into the floor or jitter apart;
- closing the window a few seconds after the last drop prints
fast movers now: 0.
๐ก Hint
If crates explode apart on contact, the normal points the wrong way: check the dot product with center_b - center_a. If they land but never tip over, the spin lines in apply_impulse are missing or have swapped signs (A uses -=, B uses +=, exactly like the velocity lines). If a glancing touch stops a crate dead, the friction impulse isn't clamped; if sliding crates spin up instead of slowing down, a sign in the friction or spin update is flipped.
โ Example Solution
The lab file your instructor hands out also contains a few lines marked lab runtime and and frame_budget() in the loop condition. They let the checker run the program for a fixed number of frames; they do nothing when you run it yourself.
"""Tumbling Crates: Advanced Lesson 11 practice exercise (solution).
Rotating boxes fall onto a floor and a ramp. SAT finds each overlap,
and an impulse at the contact point changes both the velocity and the
spin, with Coulomb friction. Click to drop a crate, R resets, D shows
contact normals.
"""
import math
import random
import pygame
from pygame import Vector2
WIDTH, HEIGHT = 900, 600
GRAVITY = Vector2(0, 980) # px/sยฒ
STEP = 1 / 120 # fixed physics step, in seconds (used by the accumulator)
ITERATIONS = 6 # solver passes over the contacts per step
SLOP = 0.5 # px of overlap we tolerate
PERCENT = 0.4 # share of the remaining overlap fixed per pass
RESTING_SPEED = 30 # px/s: slower impacts do not bounce (stops resting jitter)
MAX_CRATES = 14
TEXT_COLOR = (230, 230, 230)
def cross(a, b):
"""2D cross product: a scalar (the z of the 3D cross)."""
return a.x * b.y - a.y * b.x
def spin_velocity(omega, r):
"""Velocity of a point at offset r on a body spinning at omega rad/s (omega x r)."""
return Vector2(-omega * r.y, omega * r.x)
def box_points(w, h):
return [Vector2(-w / 2, -h / 2), Vector2(w / 2, -h / 2), Vector2(w / 2, h / 2), Vector2(-w / 2, h / 2)]
def polygon_inertia(points, mass):
"""Moment of inertia of a solid convex polygon about its centroid (points centered on (0, 0))."""
num = den = 0.0
for i, a in enumerate(points):
b = points[(i + 1) % len(points)]
area2 = abs(cross(a, b))
num += area2 * (a.dot(a) + a.dot(b) + b.dot(b))
den += area2
return mass * num / (6 * den)
class Body:
def __init__(self, points, pos, mass, angle=0.0, color=(200, 150, 90), restitution=0.2, friction=0.6):
self.local = [Vector2(p) for p in points]
self.pos = Vector2(pos)
self.vel = Vector2()
self.angle = angle
self.omega = 0.0 # rad/s
self.restitution = restitution
self.friction = friction
self.color = color
if mass == 0: # static: infinite mass, never moves
self.inv_mass = self.inv_inertia = 0.0
else:
self.inv_mass = 1 / mass
self.inv_inertia = 1 / polygon_inertia(self.local, mass)
def world_points(self):
return [self.pos + p.rotate_rad(self.angle) for p in self.local]
def edge_normals(points):
normals = []
for i, a in enumerate(points):
edge = points[(i + 1) % len(points)] - a
if edge.length_squared() > 0:
normals.append(Vector2(-edge.y, edge.x).normalize())
return normals
def project(points, axis):
dots = [p.dot(axis) for p in points]
return min(dots), max(dots)
def sat(pa, pb):
"""Separating Axis Test for two convex polygons (world points).
Returns None when they are apart, else (normal, depth) where normal
points from A to B and depth is the overlap along it.
"""
best_axis, best_depth = None, math.inf
for axis in edge_normals(pa) + edge_normals(pb):
min_a, max_a = project(pa, axis)
min_b, max_b = project(pb, axis)
overlap = min(max_a, max_b) - max(min_a, min_b)
if overlap <= 0:
return None # found a gap: not touching
if overlap < best_depth:
best_axis, best_depth = axis, overlap
center_a = sum(pa, Vector2()) / len(pa)
center_b = sum(pb, Vector2()) / len(pb)
if (center_b - center_a).dot(best_axis) < 0:
best_axis = -best_axis # always A -> B
return best_axis, best_depth
def inside(point, points):
"""True if point is inside the convex polygon (either winding)."""
signs = [cross(points[(i + 1) % len(points)] - a, point - a) for i, a in enumerate(points)]
return all(s >= -1e-9 for s in signs) or all(s <= 1e-9 for s in signs)
def contact_points(pa, pb, normal):
"""The corners that poke into the other shape (at most two are used)."""
corners = [p for p in pa if inside(p, pb)] + [p for p in pb if inside(p, pa)]
if not corners:
corners = [min(pb, key=lambda p: p.dot(normal))] # edges cross, no corner inside
return corners[:2]
def apply_impulse(a, b, impulse, ra, rb):
"""Equal and opposite impulse at the contact: changes velocity AND spin."""
a.vel -= impulse * a.inv_mass
a.omega -= cross(ra, impulse) * a.inv_inertia
b.vel += impulse * b.inv_mass
b.omega += cross(rb, impulse) * b.inv_inertia
def resolve(a, b, normal, point):
"""Normal impulse with restitution, then Coulomb friction, at one contact point."""
ra, rb = point - a.pos, point - b.pos
rel = (b.vel + spin_velocity(b.omega, rb)) - (a.vel + spin_velocity(a.omega, ra))
vn = rel.dot(normal)
if vn < 0: # only if the contact points are closing
ran, rbn = cross(ra, normal), cross(rb, normal)
k = a.inv_mass + b.inv_mass + ran * ran * a.inv_inertia + rbn * rbn * b.inv_inertia
e = min(a.restitution, b.restitution) if vn < -RESTING_SPEED else 0.0
j = -(1 + e) * vn / k
apply_impulse(a, b, normal * j, ra, rb)
rel = (b.vel + spin_velocity(b.omega, rb)) - (a.vel + spin_velocity(a.omega, ra))
tangent = rel - normal * rel.dot(normal)
if tangent.length_squared() > 1e-12:
t = tangent.normalize()
rat, rbt = cross(ra, t), cross(rb, t)
kt = a.inv_mass + b.inv_mass + rat * rat * a.inv_inertia + rbt * rbt * b.inv_inertia
jt = -rel.dot(t) / kt # the impulse that would stop the sliding
mu = math.sqrt(a.friction * b.friction)
jt = max(-mu * j, min(mu * j, jt)) # Coulomb: |friction| <= mu * normal impulse
apply_impulse(a, b, t * jt, ra, rb)
def separate(a, b, normal, depth):
"""Push overlapping bodies apart along the normal, split by inverse mass."""
total = a.inv_mass + b.inv_mass
if total > 0:
push = max(depth - SLOP, 0) * PERCENT / total
a.pos -= normal * (push * a.inv_mass)
b.pos += normal * (push * b.inv_mass)
def step_world(bodies, dt, contacts=None):
"""Advance every body by one fixed step of dt seconds."""
for body in bodies:
if body.inv_mass > 0:
body.vel += GRAVITY * dt
body.pos += body.vel * dt
body.angle += body.omega * dt
for _ in range(ITERATIONS):
if contacts is not None:
contacts.clear()
points = [body.world_points() for body in bodies]
boxes = [pygame.FRect(min(p.x for p in pts), min(p.y for p in pts),
max(p.x for p in pts) - min(p.x for p in pts),
max(p.y for p in pts) - min(p.y for p in pts)) for pts in points]
for i in range(len(bodies)):
for j in range(i + 1, len(bodies)):
a, b = bodies[i], bodies[j]
if a.inv_mass == 0 and b.inv_mass == 0:
continue
if not boxes[i].colliderect(boxes[j]):
continue # cheap broad phase: bounding boxes apart
hit = sat(points[i], points[j])
if hit is not None:
normal, depth = hit
for point in contact_points(points[i], points[j], normal):
resolve(a, b, normal, point)
if contacts is not None:
contacts.append((point, normal))
separate(a, b, normal, depth)
def make_scene(rng):
floor = Body(box_points(WIDTH, 40), (WIDTH / 2, HEIGHT - 20), 0, color=(90, 96, 110))
ramp = Body(box_points(420, 24), (230, 380), 0, angle=math.radians(22), color=(90, 96, 110))
wall = Body(box_points(30, HEIGHT), (WIDTH + 10, HEIGHT / 2), 0, color=(90, 96, 110))
bodies = [floor, ramp, wall]
for i in range(4):
bodies.append(make_crate(rng, (150 + i * 180, 80 + 40 * i)))
return bodies
def make_crate(rng, pos):
w, h = rng.randint(40, 90), rng.randint(30, 70)
color = (rng.randint(170, 240), rng.randint(110, 170), rng.randint(60, 110))
return Body(box_points(w, h), pos, mass=w * h / 1000, angle=rng.uniform(0, math.pi), color=color)
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Tumbling Crates")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 24)
rng = random.Random(11)
bodies = make_scene(rng)
contacts = []
debug = True
accumulator = 0.0
simulated = 0.0
running = True
while running:
dt = min(clock.tick(60) / 1000, 0.25) # clamp long frames (window drag)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
if len(bodies) - 3 < MAX_CRATES:
bodies.append(make_crate(rng, event.pos))
elif event.type == pygame.KEYDOWN and event.key == pygame.K_r:
bodies = make_scene(rng)
elif event.type == pygame.KEYDOWN and event.key == pygame.K_d:
debug = not debug
accumulator += dt
while accumulator >= STEP:
step_world(bodies, STEP, contacts)
accumulator -= STEP
simulated += STEP
screen.fill((24, 26, 34))
for body in bodies:
points = body.world_points()
pygame.draw.polygon(screen, body.color, points)
pygame.draw.polygon(screen, (20, 20, 20), points, 2)
if debug:
for point, normal in contacts:
pygame.draw.circle(screen, (255, 80, 80), point, 4)
pygame.draw.line(screen, (255, 80, 80), point, point + normal * 20, 2)
hud = f"crates {len(bodies) - 3} | contacts {len(contacts)} | [click] drop crate [R] reset [D] debug"
screen.blit(font.render(hud, True, TEXT_COLOR), (10, 10))
pygame.display.flip()
pygame.quit()
moving = sum(1 for b in bodies if b.inv_mass > 0 and b.vel.length() > 30)
print(f"Simulated {simulated:.1f} s with {len(bodies) - 3} crates; fast movers now: {moving}")
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal. Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Explain the Separating Axis Theorem with the flashlight-and-shadow picture to someone who hasn't seen it. Why is one gap enough to say "not touching"?
- In your own words, what do the (r ร n)ยฒ / I terms do to a collision? Describe a real object you have seen behave that way.
- Which bug did debug drawing (or a failing test) help you find today? What did it look like before you understood it?
๐ Summary
You extended collisions from circles to rotating convex polygons. The Separating Axis Theorem projects both shapes onto every edge normal: one gap means no collision, and when every axis overlaps, the smallest overlap gives the push-out depth and a normal that you orient from A to B. Rigid bodies now spin, and the velocity at a contact point includes ฯ ร r. Contact impulses use an effective mass with spin terms and change both velocity and spin, friction is capped by Coulomb's law, and the push-out is split by inverse mass. Finally, you separated sub-stepping from true continuous collision detection and computed a swept time of impact.
๐ Key Takeaways
- SAT for convex polygons: test every edge normal of both shapes; one gap means apart.
- The smallest overlap is the minimum translation vector; always orient its normal from A to B.
- Point velocity =
vel + spin_velocity(omega, r); inertia resists spin as mass resists motion. - Effective mass = 1/ma + 1/mb + (ra ร n)ยฒ/Ia + (rb ร n)ยฒ/Ib, for the normal and the tangent alike.
- Friction goes through the same impulse function and is clamped to ฮผ times the normal impulse.
- Sub-stepping only reduces tunneling; continuous collision detection solves for the time of impact.
๐ญ Looking Ahead
You have the pieces of a physics engine but not the engine. In the next lesson, Build a Physics Engine (+ pymunk), you organize bodies, shapes, contacts and joints into a World with a fixed-step solver, compare integrators, and then rebuild the same scene with pymunk.
โ Common Questions
Does SAT work for concave shapes?
No. A concave shape can have overlapping shadows on every axis while the shapes themselves don't touch (think of a C wrapped around a small ball). Split concave shapes into convex pieces and test each piece.
Should I skip parallel axes?
You can, but you don't have to: a box's four edges give only two distinct directions, so testing all four finds the same answers twice. Skipping duplicates is a small speed-up; the lab keeps the code simple and tests every edge.
Why does the solver run over all contacts several times per step?
Fixing one contact can disturb another: pushing the bottom crate of a pile changes how it presses on the crate above. Repeating the pass lets those corrections settle. More iterations give stiffer, calmer piles and cost proportionally more time; the lab uses 6.
My crates slowly sink into the floor. What is wrong?
Impulses fix velocities, not positions, so tiny overlaps add up. The positional correction in separate() pushes bodies apart by a share of the remaining depth. If it is missing or its PERCENT is 0, piles sink. If it is too large (close to 1), piles jitter instead.
Is this how real engines do it?
The ideas are the same: SAT or a related test for the normal, contact points, impulses with spin terms, clamped friction and several solver iterations. Production engines add contact clipping, impulses that are accumulated and clamped across iterations, warm starting between frames, and sleeping bodies. Next lesson you meet accumulated impulses in your own engine and the rest through pymunk.
๐ฏ Quick Quiz
Question 1: While testing two convex polygons, SAT finds a gap between their shadows on one axis. What can you conclude?
Question 2: Why does sat() flip the best axis when it points from B's center toward A's?
Question 3: In the effective mass, what does the term cross(ra, n) ** 2 * a.inv_inertia account for?
Question 4: What does the Coulomb clamp jt = max(-mu * j, min(mu * j, jt)) guarantee?
Question 5: A 3000 px/s bullet passes through a thin wall between two frames. Which fix is true continuous collision detection?
๐ Going Further
- More shapes: add triangles and hexagons to the crate scene.
polygon_inertiaalready handles any convex polygon whose points are centered on its centroid. - Ball on the ramp: add a dynamic circle and use
circle_polygon()for circleโcrate and circleโramp contacts. Give it inertia ยฝ m rยฒ and watch friction make it roll. - Sleeping: count how long each crate's speed and spin stay below a small threshold, and skip integrating it after half a second until something hits it.
- Bullets: add a fast circle that uses
circle_toi()against crate bounding circles, then SAT for the final hit. - Read more: Erin Catto's Box2D publications page collects his GDC talks on contact solvers, and Randy Gaul's tutorial series "How to Create a Custom 2D Physics Engine" walks through the same formulas step by step.