Two shapes on a screen either share a point or they do not. Games, physics simulations, drawing tools and the hit-testing behind every user interface ask this question many times per frame, and they usually want more than a yes or a no: how deep is the overlap, and which way is out?
This guide derives the classic tests for four pairs of shapes: two axis-aligned boxes, two circles, a circle against a box, and two convex polygons with the separating axis theorem. Each section has a figure to experiment with. Drag the shapes, or select one with the Tab key and move it with the arrow keys; the numbers under each figure come from the same code the text describes.
Coordinates are screen coordinates: grows to the right and grows downwards, as in SVG and the HTML canvas. Distances are measured in the figure’s own units, labelled px.
What a collision test answers
Treat every shape as a closed set, boundary included. Two shapes that only share boundary points therefore intersect: they touch. Touching counts as a collision, which is what a physics engine needs for an object resting on the floor. Every test below sorts a pair into one of three cases, separated, touching or overlapping, and reports:
- the penetration depth , how far the shapes interpenetrate;
- the normal , a unit vector pointing from shape towards shape ;
- the minimum translation vector (MTV) , the shortest translation of after which the two shapes only touch;
- a contact point where the overlap is, or, when the shapes are apart, the distance between them and the two nearest points.
Formally, the MTV is the shortest vector for which the interiors of and are disjoint, where . Moving by works just as well, and a physics step often moves each body part of the way.
Two boxes: AABB against AABB
An axis-aligned bounding box (AABB) is a rectangle whose sides are parallel to the axes, the set . It is the simplest shape to test and, as the last section shows, the one used everywhere as a first filter.
Intervals first
On a line, two closed intervals and are disjoint exactly when one lies entirely to the left of the other, that is when or . So they intersect exactly when
A box is the product of an interval on and an interval on , and the intersection of two products is the product of the intersections:
This set is empty as soon as one factor is. Two boxes collide exactly when their shadows on the axis overlap and their shadows on the axis overlap: four comparisons and no arithmetic.
How deep, and which way out
To separate the boxes along , can slide right until its left side meets ’s right side, a distance of , or left until its right side meets ’s left side, a distance of . The cheaper of the two is the overlap on that axis:
A positive is an overlap, zero means the shadows touch, and a negative value is a gap of . The boxes collide when ; the penetration depth is and the MTV points along the axis that attains it. When the boxes are apart, the distance between them combines the two gaps:
const overlapX = Math.min(a.max.x - b.min.x, b.max.x - a.min.x);
const overlapY = Math.min(a.max.y - b.min.y, b.max.y - a.min.y);
const hit = overlapX >= 0 && overlapY >= 0;
const depth = hit ? Math.min(overlapX, overlapY) : 0;
Note that is the length of the push, not the length of the common part of the shadows. The two differ when one box sits inside the other. A shadow inside shares only 2 units with it, yet it must travel 7 units to the right, or 5 to the left, to get out. Drag the small box into the large one below and watch the depth grow past the height of the shaded overlap.
Colliding — depth 25 px
- Overlap on x
- 25 px
- Overlap on y
- 95 px
- MTV
- (25, 0)
- Contact point
- (338, 223)
Drag a shape, or select it with Tab and move it 5 px with the arrow keys (Shift: 25 px).
Two axis-aligned boxes. Their shadows on the axis (bottom) and the axis (left) are drawn along the edges; A’s is solid and B’s dashed. The boxes collide exactly when both pairs of shadows overlap. The shaded rectangle is the overlap, the arrow is the MTV, and the dashed outline shows where B would rest after moving by it.
Two circles
A circle with centre and radius stands here for the whole disc, every point within distance of . Two discs intersect exactly when
One direction is the triangle inequality: a point in both discs gives . For the other, walk from towards : the point at distance lies in both discs. In code, compare squares and skip the square root: the test is with .
The normal is the direction of the line of centres, , and the depth is the amount by which the radii overshoot the distance:
This is the minimum translation, not just one that works. If moves by , the centres must end up at least apart, and , so ; moving along achieves it. The two deepest points are on and on ; halfway between them is a natural contact point.
One configuration has no line of centres at all: concentric circles. Any direction is then as good as any other, so the code picks one, here , rather than dividing by zero.
Colliding — depth 19 px
- Centre distance
- 156 px
- Sum of radii
- 175 px
- MTV
- (19, −2)
- Contact point
- (325, 193)
Drag a shape, or select it with Tab and move it 5 px with the arrow keys (Shift: 25 px).
Two circles. The dashed line joins the centres; the shaded lens is the overlap. The values under the drawing compare the distance between the centres with the sum of the radii, and the arrow, the MTV, runs along the line of centres from B’s deepest point to A’s.
A circle against a box
Mixing the two shapes needs one idea: find the point of the box nearest to the circle’s centre , and compare its distance with the radius. The squared distance splits into two independent terms,
and the box constrains and separately, to and . So each term can be minimised on its own, by moving each coordinate of into its range:
The circle and the box collide exactly when . The depth is and the normal points from to .
const p = { x: clamp(c.x, box.min.x, box.max.x), y: clamp(c.y, box.min.y, box.max.y) };
const dx = c.x - p.x;
const dy = c.y - p.y;
const hit = dx * dx + dy * dy <= r * r;
The box’s sides, extended, split the plane into nine regions, and the figure draws those lines. Beside a side, one coordinate is clamped and slides along that side. Beyond a corner, both are clamped and is the corner itself. Inside the box nothing is clamped and .
That last case is the one a hasty implementation gets wrong. With the vector is zero, and normalising it produces not-a-number. Geometrically the circle has to leave through a side, and the cheapest side is the nearest one. With the smallest of the four distances , , and , the depth is
and the normal is that side’s outward normal. No shorter push works: the centre must travel at least to leave the box and then more to take the whole disc with it.
Colliding — depth 25 px
- Closest point p
- (385, 145)
- Centre of B
- outside the box
- Distance |c − p|
- 45 px
- MTV
- (23, −11)
Drag a shape, or select it with Tab and move it 5 px with the arrow keys (Shift: 25 px).
A box and a circle. The dotted lines extend the box’s sides; marks the point of the box nearest to the centre , found by clamping. Drag the circle’s centre into the box to see the other case: the nearest side lights up and the MTV pushes the circle out through it.
The separating axis theorem
Boxes that rotate and polygons in general need a more general idea. Look again at the box test: two boxes are disjoint exactly when their shadows on or on have a gap. The separating axis theorem (SAT) says the same thing works for any two convex shapes, provided the right axes are tried.
Theorem. Two convex polygons and are disjoint if and only if there is a direction , a separating axis, on which their projections
are disjoint intervals. Moreover can always be chosen among the edge normals of and .
The algorithm follows directly: for every edge normal of either polygon, project both polygons onto it and compare the two intervals. One gap proves that the shapes are apart. If no axis has a gap, they intersect.
Projections and candidate axes
The dot product is a linear function of , and a linear function on a polygon takes its extreme values at vertices, so the projection of a polygon with vertices is
For an edge from to with direction , a normal is . Parallel edges give the same axis, so a rectangle contributes two axes, a triangle three and a regular hexagon three. To decide whether the shapes collide, the normals need not have unit length; to measure how far they overlap, they must.
Why the edge normals suffice
The cleanest argument goes through the Minkowski difference
- and share a point exactly when , since a common point gives .
- is the Minkowski sum of and . For convex polygons this sum is again a convex polygon, and its edges are the edges of and of , sorted by direction. Its edge normals are therefore edge normals of or of , up to sign.
- A convex polygon is the intersection of the half-planes of its edges, where is the outward normal and the largest value of on the polygon. So lies outside it exactly when some .
- On that largest value is
and says that all of ’s shadow on lies below all of ’s: the projections are disjoint.
Put together: if the polygons are disjoint, one of the edge normals of or separates them, and a separating axis of any kind proves disjointness. The case is the touching case, with on the boundary of .
The same picture gives the MTV for free. Moving by moves by , so the shortest translation that pushes out of the difference is the distance from to its boundary. For a convex polygon containing , that distance is the smallest . Per candidate axis this is the same “cheaper of two pushes” as for boxes, taken over both directions of the axis:
The axis of least overlap is therefore not a heuristic: with unit normals it gives the exact penetration depth, and the MTV points along it.
/** Penetration depth of two convex polygons, or null when an axis separates them. */
function sat(a: Vec[], b: Vec[]): number | null {
let depth = Infinity;
for (const poly of [a, b]) {
for (let i = 0; i < poly.length; i++) {
const e = sub(poly[(i + 1) % poly.length], poly[i]);
const n = normalize({ x: e.y, y: -e.x });
const [minA, maxA] = project(a, n);
const [minB, maxB] = project(b, n);
const overlap = Math.min(maxA - minB, maxB - minA);
if (overlap < 0) return null; // a separating axis: no collision
depth = Math.min(depth, overlap);
}
}
return depth;
}
The figure runs a fuller version of this loop. It keeps every axis instead of stopping at the first gap, so that the list under the drawing can show them all, and it also returns the direction, the contact point and the overlap region.
Colliding — depth 39 px
- Candidate axes
- 5
- Separating axis
- none
- MTV
- (37, −10)
- Contact point
- (340, 208)
Projections on each axis
On the figure: A2 (automatic)
Choose an axis to draw it on the figure; choose it again to go back to automatic.
Drag a shape, or select it with Tab and move it 5 px with the arrow keys (Shift: 25 px). Q and E rotate it by 15°.
A rotated rectangle A and a convex polygon B. The list shows every candidate axis with the shadows of A (solid) and B (outlined). The drawing shows one axis with the lines that cast the shadows: the separating axis when there is one, otherwise the axis of the MTV. Choose an axis in the list to draw it instead, and rotate with Q and E or the buttons.
A few things to try. Move B until it just clears a corner of A: often only one of B’s axes finds the gap, while A’s own axes still see overlapping shadows. Switch B to the rectangle and rotate it until its sides line up with A’s: its axes become parallel to A’s, and the list marks them as adding nothing new. With the pentagon there are seven candidate axes, and the depth is always the smallest overlap among them.
Oriented rectangles and triangles
An oriented rectangle with centre , half-extents and and angle has the corners
Its two axes are the columns of , and , so they need not be computed from edges at all. Its projection onto a unit axis is centred at with radius , which saves projecting four corners. Two oriented rectangles need four axes; a box is the case , where the four collapse to the familiar two.
A triangle contributes three axes. The sign of the cross product gives its orientation, which decides whether points out of the triangle or into it. The orientation does not matter to the SAT test itself, since an interval and its mirror image overlap together, but outward normals make the MTV’s direction easier to reason about. The same cross product, applied to the three sub-triangles , and , tests whether a point lies inside: it does when none of the three signs is opposite to the whole triangle’s.
Why convexity matters
The proof used convexity twice: in step 2, where turned out to be a convex polygon, and in step 3, where a point outside a convex polygon is outside one of its edge half-planes. For a concave shape the conclusion fails. In the figure below a small square sits inside the notch of a U without touching it. Every candidate axis sees overlapping shadows, so SAT reports a collision, yet no straight line can pass between the two shapes: the gap lies inside the notch.
SAT: colliding — wrong, the shapes are 25 px apart
- SAT (whole U)
- colliding
- Convex pieces
- separated
- Distance
- 25 px
- Separating axis
- none
Drag a shape, or select it with Tab and move it 5 px with the arrow keys (Shift: 25 px).
SAT on a concave shape. The U’s only edge directions are horizontal and vertical, and on both axes the shadows of the U and the square overlap, so SAT reports a hit. The dashed lines cut the U into three convex pieces; testing the square against each piece gives the true answer.
The usual remedy is to split a concave polygon into convex pieces , for instance into triangles by ear clipping or into fewer pieces by a convex decomposition, and to test each piece. The shapes collide exactly when some piece does. The depth needs more care: the largest of the pieces’ depths is only a lower bound for the depth of the whole shape, because a push out of one piece can lead straight into another. For the exact MTV the pieces must be taken together: it is again the shortest way from to the boundary, now the boundary of the union of the Minkowski differences .
What it costs
The first three tests take constant time: four comparisons for two boxes; one squared distance against a squared sum for two circles; two clamps and one squared distance for a circle and a box.
SAT on polygons with and vertices projects vertices onto each of up to axes, which is work, with an early exit at the first gap. For the small polygons of a typical game that is fast. For shapes with many vertices, or for 3D, iterative algorithms such as GJK (Gilbert–Johnson–Keerthi), which works with the Minkowski difference through support points, together with EPA (the expanding polytope algorithm) for the depth, scale better.
The bigger cost is the number of pairs. A scene of shapes has of them: 499,500 pairs for a thousand shapes, in every frame.
Practical notes
Broad phase, then narrow phase
Real systems split the work in two. A broad phase discards most pairs cheaply and conservatively: it wraps every shape in an axis-aligned bounding box (for an oriented rectangle, the box around its four corners; for a circle, the square of side ) and finds overlapping boxes with a spatial structure, such as a uniform grid, sort and sweep along one axis, or a bounding volume hierarchy. Only the surviving candidates reach the exact tests above, the narrow phase. The humble box test thus does double duty.
Floating-point tolerance
Configurations that touch exactly on paper rarely touch exactly in floating point. In IEEE doubles, , and adding ten times gives . Rotating a rectangle by uses rather than , so a corner that should land on lands on , and two rectangles that should touch overlap by about . A few practical rules follow:
- Define touching with a tolerance: an overlap within counts as touching. The figures in this post use px, about what the eye can tell apart; for general use the code behind them scales a default of with the size of the coordinates, because rounding errors grow with the numbers.
- Compare squared lengths when only a yes or no is needed, and normalise vectors only when a depth or a direction is required.
- Guard every division by a length that can be zero: concentric circles, a centre inside a box, zero-length edges, and polygons that collapse to a segment or a point.
Resolving the collision
The MTV is the simplest resolution: move one shape by it, or split it between the two in proportion to their masses. A physics engine also needs the normal, to remove the part of the velocity that points into the other body. One more pitfall: a fast, small object can pass through a thin one between two frames without ever overlapping it in a single snapshot. Continuous collision detection, which tests the swept shape or solves for the time of impact, or simply smaller time steps, prevents this tunnelling.
Summary
| Pair | Collide exactly when | Penetration depth | Cost |
|---|---|---|---|
| Box and box | shadows overlap on and on | ||
| Circle and circle | |||
| Circle and box | , clamped | , or inside | |
| Convex polygons | no edge normal separates the shadows | smallest overlap over the edge normals |
Each test is the same idea at a different level of generality: project onto an axis where the question becomes one-dimensional, and look for a gap. The code behind the figures, with tests for the edge cases discussed here (touching shapes, nested shapes, degenerate polygons, a rotation by ), is in the site’s repository under src/lib/geometry.