Home Games Shader Sandbox

Game Dev Mechanics: Separating Axis Theorem (SAT) — How It Works

NO COLLISION
Drag shapes to test collision detection
Triangle
Rectangle
Pentagon
MTV

Every game that features moving objects must answer a fundamental question at each frame: are these two things touching? Whether it is a sword colliding with a shield, a player landing on a platform, or a bullet striking a wall, collision detection is the invisible contract between your game world and the laws of physics. Get it wrong and objects ghost through solid walls; get it right and the world feels dense, satisfying, and real.

Simple shapes are easy to test. Two circles collide when the distance between their centers is less than the sum of their radii. Two axis-aligned boxes overlap when their projections on both the X and Y axes intersect. But the moment your game features a rotated crate, a sloped floor, or an irregular asteroid — the simple tests break down. You need something that can handle any convex shape, in any orientation, without special-casing every combination.

That something is the Separating Axis Theorem — SAT for short. It is mathematically elegant, computationally efficient, and powerful enough to detect collisions between arbitrary convex polygons. It also produces the Minimum Translation Vector (MTV), telling you exactly how far and in which direction to push one shape out of another. This is the foundation of realistic physics resolution.

Why Simple Tests Fall Short

An Axis-Aligned Bounding Box (AABB) test checks if two rectangles aligned to the world axes overlap. This works for grid-based games, but as soon as either shape rotates, the AABB becomes a loose approximation. A narrow diagonal sword rotated 45 degrees has an AABB nearly twice its actual width — you will get false positives everywhere.

Bounding circles avoid the rotation problem but introduce a different one: they waste too much space for non-circular objects. A long thin rectangle has a bounding circle whose radius is half the diagonal, leaving wasted space at the corners where the circle claims space the shape does not actually occupy.

The natural solution is to test the actual shapes. But how do you test if a rotated pentagon and a rotated triangle overlap? Testing every vertex pair gets complex fast. Checking if any edges intersect misses the case where one shape is entirely inside another. SAT solves all of this with a single approach.

The Core Insight: The Separating Hyperplane

The Separating Axis Theorem is rooted in a simple geometric principle: two convex shapes do NOT overlap if and only if there exists a line (in 2D) or a plane (in 3D) that completely separates them.

Imagine holding two non-overlapping objects. You can always slide a flat piece of paper between them. That piece of paper is the separating hyperplane. If no such plane exists (if you cannot slide paper between them no matter how you try), then the objects are colliding.

The theorem tells us something even more useful: if a separating plane exists, it can always be found by testing a finite set of candidate axes — specifically, the axes perpendicular to each edge of both shapes. This transforms an infinite geometric search into a finite set of projection tests.

Edge Normals as Candidate Axes

For a convex polygon, the candidate separating axes are the outward-facing normals of each edge. An edge normal is a vector perpendicular to that edge, pointing outward from the shape's interior.

Given an edge from vertex $A$ to vertex $B$, the edge vector is:

$$\vec{e} = B - A = (e_x,\, e_y)$$

The perpendicular (left-hand) normal is obtained by rotating 90 degrees:

$$\hat{n} = \text{normalize}(-e_y,\, e_x)$$

For two shapes with $m$ and $n$ edges respectively, we generate $m + n$ candidate axes. For a triangle and a rectangle, that is $3 + 4 = 7$ axes to test. This approach scales well even for shapes with many edges. In practice you can often exit early the moment you find a gap.

// Get edge normals (potential separating axes) from a convex polygon
function getEdgeNormals(vertices) {
    const normals = [];
    const n = vertices.length;
    for (let i = 0; i < n; i++) {
        const a = vertices[i];
        const b = vertices[(i + 1) % n];
        const ex = b.x - a.x;
        const ey = b.y - a.y;
        const len = Math.sqrt(ex * ex + ey * ey);
        // Perpendicular, normalized
        normals.push({ x: -ey / len, y: ex / len });
    }
    return normals;
}

Projections: Casting Shadows onto an Axis

For each candidate axis, we project both shapes onto it and check whether the resulting intervals overlap. Projection here means computing a 1D shadow of the shape along a directed line.

For a single vertex $\vec{v}$ and a normalized axis $\hat{n}$, the scalar projection is the dot product:

$$d = \vec{v} \cdot \hat{n} = v_x n_x + v_y n_y$$

Doing this for all vertices of a shape gives a set of scalar values. The minimum and maximum of this set form the shape's projection interval on that axis:

$$\text{proj}(\text{shape}, \hat{n}) = \left[\min_i(\vec{v}_i \cdot \hat{n}),\; \max_i(\vec{v}_i \cdot \hat{n})\right]$$

Think of shining a light perpendicular to the axis. The projection interval is the shadow the shape casts on the axis line.

// Project all vertices onto a normalized axis, return {min, max} interval
function project(vertices, axis) {
    let min = Infinity, max = -Infinity;
    for (const v of vertices) {
        const d = v.x * axis.x + v.y * axis.y;
        if (d < min) min = d;
        if (d > max) max = d;
    }
    return { min, max };
}

The Algorithm: Test Every Axis, Look for a Gap

Once we have projection intervals for both shapes on a given axis, the check is simple: do the intervals overlap? Two intervals $[a_{\min}, a_{\max}]$ and $[b_{\min}, b_{\max}]$ have a gap (are separated) if and only if:

$$a_{\max} < b_{\min} \quad \text{or} \quad b_{\max} < a_{\min}$$

If a gap exists on any single axis, we have found our separating hyperplane and can immediately conclude: no collision. This early-exit behavior makes SAT fast in practice — non-colliding shapes (the majority case in most games) are resolved quickly.

If we test every candidate axis and find overlap on all of them, then no separating axis exists and the shapes are colliding.

The overlap amount on each axis is:

$$\text{overlap} = \min(a_{\max}, b_{\max}) - \max(a_{\min}, b_{\min})$$

We track which axis produced the minimum overlap, because that becomes the direction of least resistance: the Minimum Translation Vector.

The Minimum Translation Vector

Knowing that two shapes collide is useful, but physics engines need to know what to do about it. This is where the Minimum Translation Vector (MTV) becomes critical.

The MTV is the smallest vector by which one shape can be displaced to resolve the collision. It pushes the shapes apart so they are just touching and no longer overlapping. It is defined as:

$$\vec{\text{MTV}} = \hat{n}_{\min} \cdot \text{overlap}_{\min}$$

Where $\hat{n}_{\min}$ is the axis with the minimum overlap amount. The direction of $\hat{n}_{\min}$ must be flipped if necessary so it points from shape A toward shape B (away from A's centroid, toward B's centroid):

// Ensure MTV points from A toward B
const cA = centroid(vertsA);
const cB = centroid(vertsB);
const d = { x: cB.x - cA.x, y: cB.y - cA.y };
if (d.x * mtvAxis.x + d.y * mtvAxis.y < 0) {
    mtvAxis.x = -mtvAxis.x;
    mtvAxis.y = -mtvAxis.y;
}

In a physics engine, you would then translate shape B by the full MTV (or split it: translate A by $-0.5 \cdot \vec{\text{MTV}}$ and B by $+0.5 \cdot \vec{\text{MTV}}$) to resolve the penetration before applying impulse forces.

Complete SAT Implementation

Here is a full JavaScript implementation of all the pieces together:

function satCollide(vertsA, vertsB) {
    const axes = [
        ...getEdgeNormals(vertsA),
        ...getEdgeNormals(vertsB)
    ];

    let minOverlap = Infinity;
    let bestAxis = null;

    for (const axis of axes) {
        const pA = project(vertsA, axis);
        const pB = project(vertsB, axis);

        // Gap found — no collision, early exit
        if (pA.max < pB.min || pB.max < pA.min) {
            return { hit: false, mtv: null };
        }

        // Track minimum overlap axis for MTV
        const overlap = Math.min(pA.max, pB.max)
                      - Math.max(pA.min, pB.min);
        if (overlap < minOverlap) {
            minOverlap = overlap;
            bestAxis = { ...axis };
        }
    }

    // Flip axis to point from A toward B
    const cA = centroid(vertsA);
    const cB = centroid(vertsB);
    const d = { x: cB.x - cA.x, y: cB.y - cA.y };
    if (d.x * bestAxis.x + d.y * bestAxis.y < 0) {
        bestAxis.x = -bestAxis.x;
        bestAxis.y = -bestAxis.y;
    }

    return {
        hit: true,
        depth: minOverlap,
        normal: bestAxis,
        mtv: {
            x: bestAxis.x * minOverlap,
            y: bestAxis.y * minOverlap
        }
    };
}

// Apply MTV to resolve collision
function resolve(shapeA, shapeB, mtv) {
    shapeB.x += mtv.x;
    shapeB.y += mtv.y;
}

Handling Circles

Pure polygon SAT can be extended to handle circles by adding one additional candidate axis: the axis from the circle's center to the closest vertex of the polygon. This is the only axis direction along which a circle and polygon can be separated that is not already covered by the polygon's edge normals.

function closestVertexAxis(circleCenter, polyVerts) {
    let closestDist = Infinity;
    let closestVert = null;
    for (const v of polyVerts) {
        const dx = v.x - circleCenter.x;
        const dy = v.y - circleCenter.y;
        const dist = Math.sqrt(dx * dx + dy * dy);
        if (dist < closestDist) {
            closestDist = dist;
            closestVert = v;
        }
    }
    const dx = closestVert.x - circleCenter.x;
    const dy = closestVert.y - circleCenter.y;
    const len = Math.sqrt(dx * dx + dy * dy);
    return { x: dx / len, y: dy / len };
}

// For the circle projection, use: dot ± radius
function projectCircle(center, radius, axis) {
    const d = center.x * axis.x + center.y * axis.y;
    return { min: d - radius, max: d + radius };
}

Limitations: Convex Shapes Only

SAT only works correctly for convex shapes. A convex shape is one where any line segment connecting two points on its boundary lies entirely inside the shape. Triangles, rectangles, pentagons, and hexagons are all convex. An L-shaped room, a star, or a crescent are not.

For concave (non-convex) shapes, the standard technique is convex decomposition: split the concave shape into a union of smaller convex pieces, then run SAT between all the convex pairs. Libraries like poly-decomp.js can automate this process, and physics engines like Box2D and Chipmunk do this internally when you supply a concave polygon.

In 3D, SAT extends straightforwardly. You simply add face normals from both polyhedra and, critically, all cross products of edge pairs (one edge from each shape). This edge-edge axis set covers cases the face normals miss, particularly when two shapes are oriented so they separate along an edge-to-edge direction rather than a face direction.

Where SAT Appears in Real Games

SAT is one of the most widely deployed collision algorithms in the game industry:

  • Box2D and its derivatives (used by thousands of 2D games including Angry Birds, Limbo, and many Unity 2D projects) use SAT as the narrow-phase collision primitive for polygon shapes.
  • Bullet Physics (used by Grand Theft Auto V, Red Dead Redemption 2, many Unreal Engine games) uses GJK/EPA for general convex shapes, but its AABB and box-specific tests are essentially SAT specializations.
  • Phaser.js, the popular JavaScript game framework, implements SAT in its Phaser.Geom.Intersects module for polygon collision.
  • Unity's Physics2D system uses SAT under the hood for PolygonCollider2D components.
  • Classic arcade games like the original Asteroids used simple bounding-circle tests, but modern remakes and bullet-hell shooters commonly switch to SAT for accurate hitboxes.

Performance in Practice: Broad Phase and Narrow Phase

SAT is efficient for a pair of shapes, but a naïve implementation testing every object against every other object scales as $O(n^2)$ — disastrously slow for scenes with hundreds of objects.

The industry-standard solution is a two-phase approach:

  • Broad phase: Use a fast, approximate test (AABB overlap, bounding sphere overlap, or a spatial structure like a quadtree or spatial hash) to quickly eliminate the vast majority of object pairs that cannot possibly be colliding. This runs in roughly $O(n \log n)$.
  • Narrow phase: Run SAT only on the small set of candidate pairs that the broad phase flagged as potentially overlapping. Since most objects are not near each other, the narrow phase runs only a handful of times per frame in typical scenes.

Additionally, SAT benefits from temporal coherence: if two shapes were not colliding last frame, you can cache the last separating axis and test it first. If it is still a separating axis, you exit immediately without testing the remaining axes. This provides a significant speedup in scenes with mostly non-colliding objects.

Conclusion

The Separating Axis Theorem plays a key role in every game developer's toolkit through a combination of mathematical rigor and practical efficiency. By reducing the question of convex-shape collision to a set of 1D interval overlap tests, SAT is easy to implement and fast enough for real-time games. The MTV it produces is directly actionable by physics resolvers, making it a complete solution from detection to response.

Mastering SAT means you can build precise collision detection for rotating platforms, custom polygon hitboxes, physics puzzles, and any game mechanic that demands accuracy over approximation. Once you understand projections and axes, the rest follows. You will have a deeper understanding of how shapes interact in your game world.

Comments

Like this article? Consider supporting us

Your support helps us keep creating free game dev content, tutorials, and tools.

Free

$0 /month

Newsletter and public posts

  • Newsletter access
  • Public posts & updates
  • Community access

Studio Backer

$25 /month

Direct impact on development with your name in the credits

  • Everything in Supporter
  • Your name in game credits
  • Priority feature requests
  • Direct developer access
  • Monthly asset downloads