A collision bug rarely presents itself as a theorem.

It presents itself as a complaint:

that corner hit me
that bullet went through the wall
that crate is vibrating because the solver keeps changing its mind

Underneath the complaint is a yes-or-no question asked thousands of times per frame:

do these two shapes intersect?

The pretty answer would be to understand the whole two-dimensional picture. The useful answer is lazier. Look for one direction in which the shapes cast separate shadows. If that direction exists, they are not colliding.

One shadow is enough.

The Test

Take a unit direction \(u\). A point \(p\) lands on that axis at the scalar \(p\cdot u\). A convex polygon becomes an interval:

\[I(P,u)= \left[ \min_{p\in P} p\cdot u,\, \max_{p\in P} p\cdot u \right].\]

For two convex polygons \(A\) and \(B\):

if I(A, u) and I(B, u) are disjoint for some u,
then A and B are disjoint.

That is the easy half. The slightly surprising half is that, in 2D, we do not need to try every possible direction. For convex polygons, testing normals to the edges of both polygons is sufficient.1 David Eberly’s separating axes note states the projection criterion directly, then specializes it to convex polygons by testing edge normals.1

Why edge normals? Imagine two convex polygons just barely touching. The contact is supported by a line. In the generic cases, that line is flush with an edge of one polygon, or the limiting version of such a line. The separating direction is perpendicular to that supporting line. So the finite candidate list is:

all edge normals of A
all edge normals of B
deduplicate parallel axes
project both shapes
if any projected intervals have a gap, return "separated"
otherwise return "overlap"

Here is the whole narrow-phase idea, minus rendering:

function project(poly, axis) {
  let min = Infinity;
  let max = -Infinity;
  for (const point of poly) {
    const value = point[0] * axis[0] + point[1] * axis[1];
    min = Math.min(min, value);
    max = Math.max(max, value);
  }
  return { min, max };
}

function intervalOverlap(a, b) {
  return Math.min(a.max, b.max) - Math.max(a.min, b.min);
}

function sat(polyA, polyB, axes) {
  let minimumOverlap = Infinity;
  let minimumAxis = null;

  for (const axis of axes) {
    const a = project(polyA, axis);
    const b = project(polyB, axis);
    const overlap = intervalOverlap(a, b);

    if (overlap < 0) {
      return { colliding: false, gap: -overlap, axis };
    }

    if (overlap < minimumOverlap) {
      minimumOverlap = overlap;
      minimumAxis = axis;
    }
  }

  return { colliding: true, overlap: minimumOverlap, axis: minimumAxis };
}

Notice what the algorithm is optimized to find first: a reason to say no.

The Lab

The browser experiment below runs the actual SAT calculation from assets/js/separating-axis-lab.js. The rectangle contributes two unique axes, the pentagon contributes five, and the lab deduplicates parallel directions.

The default state is deliberately near contact:

candidate axes      7
default state       separated
largest gap         12.49 px
critical axis       A#2
pulled-in overlap   19.79 px
farther gap         73.14 px

The important panel is not the polygon panel. It is the shadow test. The shapes look close in the plane, but on one edge-normal axis their projected intervals leave a visible gap.

Polygon A Polygon B Separating gap Overlap / MTV axis Offset sweep

Deterministic browser experiment. Each frame transforms two convex polygons, builds edge-normal candidate axes, projects both polygons onto every axis, and reports the first geometric reason to reject collision, or the smallest overlap axis when no rejection remains.

Move Offset X left to about 35. The gap disappears, the state flips to overlap, and the critical axis changes meaning. It is no longer a proof of separation. It is the shallowest overlap axis, often used as a candidate minimum-translation vector for simple collision response.

That is useful, but it is not magic. A minimum-translation vector from SAT is a local geometric hint. A real physics solver still has to care about velocity, friction, restitution, stacking, constraints, warm starting, and numerical tolerance.

The Ledger

SAT is pleasantly auditable because every axis leaves a row in the ledger.

For each candidate axis:

negative margin = projected intervals are apart
positive margin = projected intervals overlap

The no-collision case has at least one negative row. The collision case has none. Among overlapping rows, the smallest positive row is the least expensive axis along which to separate the shapes.

This makes SAT a good debugging algorithm. When a hitbox feels wrong, you can print the axis ledger and see whether the claim rests on one barely-negative gap, a pile of positive overlaps, or an axis that should not have been tested because the polygon winding or convexity is broken.

The implementation detail I like is that the theorem gives you an audit log for free:

const rows = axes.map((entry) => {
  const a = project(polyA, entry.axis);
  const b = project(polyB, entry.axis);
  const overlap = Math.min(a.max, b.max) - Math.max(a.min, b.min);

  return {
    axis: entry.axis,
    edge: entry.edge,
    owner: entry.owner,
    gap: Math.max(0, -overlap),
    overlap,
    separated: overlap < 0
  };
});

You do not need a separate explanation system. The same values that decide the answer explain the answer.

What Convex Buys

The word “convex” is doing almost all the work.

If a shape is convex, every line segment between two points of the shape stays inside the shape. That prevents a projection from hiding a hole or bite mark. For concave shapes, shadows can overlap in every tested direction while the actual shapes miss, unless the shape is decomposed into convex pieces or the candidate set is changed.

In production game engines this usually becomes a hierarchy:

broad phase     cheap spatial rejection, such as grids, sweep-and-prune, BVHs
narrow phase    SAT, GJK, EPA, clipped features, shape-pair routines
solver          impulses and constraints across contacts

SAT is a narrow-phase tool. It should not be asked to compare every object against every other object. It also should not be treated as the entire physics engine.

The 3D version has the same spirit and more axes. For convex polyhedra, Eberly lists face normals plus cross products of one edge from each polyhedron.1 For oriented bounding boxes, the OBBTree paper uses a separating-axis test in which the candidate directions reduce to the three face axes of each box plus the nine pairwise edge-cross-edge directions.2 That finite list is one reason OBBs became practical bounding volumes for interactive interference detection: Gottschalk, Lin, and Manocha report OBBTrees detecting contacts among models with hundreds of thousands of polygons at interactive rates.2

SAT And GJK

SAT is not the only beautiful answer to convex collision.

The Gilbert-Johnson-Keerthi algorithm, usually called GJK, attacks the same family of problems through support functions and the Minkowski difference. The original Gilbert, Johnson, and Keerthi paper gives an algorithm for Euclidean distance between convex sets in \(\mathbb{R}^m\) and is still the reference point for many real-time collision systems.3

The cartoon comparison is:

SAT asks: does some projection axis separate the shadows?
GJK asks: does the Minkowski difference contain the origin?

SAT is direct when the candidate axes are cheap and explicit, such as boxes and small convex polygons. GJK is attractive when a shape can cheaply answer support queries, even if enumerating all useful feature axes is inconvenient. Many engines use both families somewhere, because “best collision algorithm” is not a universal category. It depends on shape representation, temporal coherence, whether you need distance, whether you need contact points, and how much numerical damage your solver can tolerate.

Christer Ericson’s Real-Time Collision Detection is still the book I would put beside this topic for implementation judgment rather than theorem collecting; the companion site collects the errata and related material.4

The Small Moral

SAT is a reminder that good geometry code often wins by proving absence.

The detector does not need a grand theory of the scene. It needs one separating axis. One direction in which the shadows do not touch. One signed margin that survives floating-point tolerance.

When there is a gap, the case is closed.

When there is no gap, the interesting engineering begins.

  1. David Eberly, “Intersection of Convex Objects: The Method of Separating Axes”, Geometric Tools, created 2001, last modified 2022.  2 3

  2. Stefan Gottschalk, Ming C. Lin, and Dinesh Manocha, “OBBTree: A Hierarchical Structure for Rapid Interference Detection”, SIGGRAPH 1996.  2

  3. Elmer G. Gilbert, Daniel W. Johnson, and S. Sathiya Keerthi, “A fast procedure for computing the distance between complex objects in three-dimensional space”, IEEE Journal of Robotics and Automation, 4(2):193-203, 1988. DOI: 10.1109/56.2083

  4. Christer Ericson, Real-Time Collision Detection companion site, Morgan Kaufmann, 2005.