An interactive walkthrough of the Separating Axis Theorem and collision response — based on the dyn4j tutorial and N game physics guide.
SAT states: if two convex shapes are not colliding, you can always find at least one axis where their projected "shadows" don't overlap. Equivalently — if every possible separating axis shows overlapping projections, the shapes must be intersecting.
The key properties: SAT only works with convex shapes. A separating axis is just a direction (a unit vector). You project both shapes onto that direction as 1D intervals, and check if those intervals are disjoint.
Drag either shape below to separate them. When a gap exists, the algorithm finds the separating axis instantly.
To test a given axis, take every vertex of a shape, compute its dot product with the axis direction vector, and record the minimum and maximum values. This gives a 1D interval — the shape's "shadow" on that axis.
min = dot(axis, vertices[0])
max = min
for each vertex v:
p = dot(axis, v)
if p < min: min = p
if p > max: max = p
return Projection(min, max)
Drag the handle at the end of the axis line below to rotate it. Dotted lines show each vertex being projected, and the thick colored segment shows the resulting [min, max] interval.
You don't need to test every possible direction — only axes that are perpendicular to each shape's edges. For two polygons with M and N sides, you test at most M+N axes. This is the insight that makes SAT practical.
axes = []
for i in range(len(vertices)):
edge = vertices[(i+1) % len] - vertices[i]
normal = perpendicular(edge) // (-edge.y, edge.x)
axes.append(normalize(normal))
Note: parallel edges produce the same axis — so a rectangle only needs 2 unique test axes, not 4.
Below: blue arrows are Shape A's edge normals, amber arrows are Shape B's. Drag either shape.
For each axis, project both shapes and measure the overlap of their intervals. If any axis gives a negative overlap — the shapes are separated on that axis, so no collision. If all axes overlap, they're colliding.
overlap = min(projA.max, projB.max) - max(projA.min, projB.min) if overlap < 0: no collision on this axis (shapes separated)
Track the axis with the smallest positive overlap — that's the Minimum Translation Vector (MTV): the shortest push needed to separate the shapes. The MTV is that axis direction scaled by that overlap amount, directed from A toward B.
Start the shapes overlapping, then hit "Resolve Collision" to push them apart.
Once we have the MTV (collision normal + depth), we apply a physical response. First, project the object out of the collision along the MTV. Then decompose its velocity into a normal component (perpendicular to the contact surface) and a tangential component (along the surface). Scale each separately.
v_normal = dot(velocity, normal) * normal // perpendicular to surface v_tangent = velocity - v_normal // along surface new_velocity = -bounce * v_normal + (1 - friction) * v_tangent
The bounce coefficient (0–1) controls elasticity: 1.0 = perfectly elastic, 0.0 = no bounce. Friction reduces the tangential (sliding) velocity on each contact.
Circles are special — they have infinitely many potential separating axes. The trick: the only extra axis you need to test (beyond all polygon edge normals) is the axis from the closest polygon vertex to the circle center.
Steps:
1. Test all polygon edge normals (same as polygon-polygon SAT)
2. Find the closest polygon vertex to the circle center
3. Add the vertex→center direction as one extra axis
4. Project the circle: its interval on any axis is [dot(center,axis) - r, dot(center,axis) + r]
When colliding, the closest vertex is highlighted and the extra axis is drawn. Drag either shape.