DEV Community

whomi928
whomi928

Posted on

Building a 2D Physics Engine From Scratch: SAT Collision Detection in C++ and SDL2

I wanted to actually understand collision detection — not "call the library function," but understand it well enough to write it myself. So I built a small 2D physics engine in C++ and SDL2, no external physics library, and picked the Separating Axis Theorem (SAT) as the core algorithm.

Here's how it works, and the gotchas I ran into turning "shapes are touching" into "shapes bounce correctly."

Why not just bounding circles?

Circle-vs-circle collision is one line: compare the distance between centers to the sum of the radii. Easy, but wrong the moment you want anything that isn't round — a square, a triangle, a pentagon. Once shapes have edges and orientation, you need real polygon-vs-polygon detection. That's what SAT gives you.

The idea behind SAT

Two convex shapes are not colliding if you can find any single axis where their projections don't overlap. So the algorithm is:

For every edge of both shapes, take the perpendicular (the edge's normal) as a candidate separating axis.
Project every vertex of both shapes onto that axis.
If the projections don't overlap on any axis, the shapes aren't colliding — you can stop immediately.
If every axis shows overlap, the shapes are colliding, and the axis with the smallest overlap tells you the cheapest way to push them apart — the minimum translation vector.

Here's the core of it:

cppCollisionResult check_collision_sat(const vector>& shape1, const vector>& shape2) {
CollisionResult result = { false, INFINITY, 0.0f, 0.0f };
const vector>* shapes[] = { &shape1, &shape2 };

for (int s = 0; s < 2; s++) {
    const vector<pair<float, float>>& current_shape = *shapes[s];

    for (int i = 0; i < current_shape.size(); i++) {
        int next_i = (i + 1) % current_shape.size();
        float edge_x = current_shape[next_i].first - current_shape[i].first;
        float edge_y = current_shape[next_i].second - current_shape[i].second;

        // perpendicular to the edge = candidate separating axis
        float axis_x = -edge_y;
        float axis_y = edge_x;
        float len = vector_length(axis_x, axis_y);
        axis_x /= len;
        axis_y /= len;

        // project both shapes onto this axis
        float minA = INFINITY, maxA = -INFINITY;
        for (const auto& p : shape1) {
            float projected = dot_product(p.first, p.second, axis_x, axis_y);
            minA = std::min(minA, projected);
            maxA = std::max(maxA, projected);
        }

        float minB = INFINITY, maxB = -INFINITY;
        for (const auto& p : shape2) {
            float projected = dot_product(p.first, p.second, axis_x, axis_y);
            minB = std::min(minB, projected);
            maxB = std::max(maxB, projected);
        }

        if (maxA < minB || maxB < minA) {
            return { false, 0.0f, 0.0f, 0.0f }; // found a gap — no collision
        }

        float overlap = std::min(maxA, maxB) - std::max(minA, minB);
        if (overlap < result.depth) {
            result.depth = overlap;
            result.normal_x = axis_x;
            result.normal_y = axis_y;
        }
    }
}

result.isColliding = true;
return result;
Enter fullscreen mode Exit fullscreen mode

}

Looping over every edge of both shapes as candidate axes is what makes this work for arbitrary convex polygons, not just a fixed set of shape types.

Detection isn't the interesting part — response is

Knowing two shapes overlap doesn't move anything. You need two separate passes after that: positional correction (untangle the overlap) and velocity resolution (actually make them bounce). I originally tried to do both in one step and got jittery, sticky collisions — separating them fixed it.

cppif (result.isColliding) {
// SAT gives you AN axis, not necessarily pointing from shape1 -> shape2.
// If it's backwards, the correction pushes shapes the wrong way.
float dir_x = e2.x - e1.x;
float dir_y = e2.y - e1.y;
if ((dir_x * result.normal_x + dir_y * result.normal_y) < 0) {
result.normal_x *= -1;
result.normal_y *= -1;
}

// --- positional correction: push apart proportional to mass ---
float total_mass = e1.mass + e2.mass;
float ratio1 = e2.mass / total_mass;
float ratio2 = e1.mass / total_mass;
e1.x -= result.normal_x * (result.depth * ratio1);
e1.y -= result.normal_y * (result.depth * ratio1);
e2.x += result.normal_x * (result.depth * ratio2);
e2.y += result.normal_y * (result.depth * ratio2);

// --- impulse resolution: the actual bounce ---
float relVelX = e2.velocity_x - e1.velocity_x;
float relVelY = e2.velocity_y - e1.velocity_y;
float velAlongNormal = (relVelX * result.normal_x) + (relVelY * result.normal_y);

if (velAlongNormal > 0) continue; // already separating, do nothing

float impulse = -(1.0f + restitution) * velAlongNormal;
impulse /= (1.0f / e1.mass) + (1.0f / e2.mass);

float impulseX = impulse * result.normal_x;
float impulseY = impulse * result.normal_y;
e1.velocity_x -= impulseX / e1.mass;
e1.velocity_y -= impulseY / e1.mass;
e2.velocity_x += impulseX / e2.mass;
e2.velocity_y += impulseY / e2.mass;
Enter fullscreen mode Exit fullscreen mode

}

Two things worth calling out:

The normal direction check. SAT hands you an axis of least overlap, but nothing guarantees it points from shape A toward shape B — it depends on which edge produced the smallest overlap. Skip the direction check and roughly half your collisions push objects into each other instead of apart. Took me a while to figure out why some collisions looked fine and others looked like objects were teleporting through each other.

Mass-weighted positional correction. Splitting the correction 50/50 regardless of mass looks wrong the moment you have a heavy shape and a light one — the light one should move more. Dividing the push by mass ratio fixes that with barely any extra code.

Where it falls apart (for now)

Being honest about the current limits:

No broadphase. Every pair of entities gets tested against every other pair, every frame — O(n²). Fine for a few dozen shapes on screen, not built to scale past that yet.
No real torque. Rotation is currently a cosmetic hack — velocity nudges the orientation angle so shapes tumble as they fall, but there's no actual angular momentum or moment of inertia driving it.
Energy creep at high restitution. Without sub-stepping, impulse resolution close to restitution = 1.0 can slowly add energy to the system over many bounces — a known trade-off of this style of solver.

What's next

Spatial partitioning (probably a simple grid/spatial hash before reaching for a quadtree), real angular velocity and torque so rotation is physically driven instead of faked, and then folding all of this into a small tile-based game engine that's currently sitting half-built on the side.

Code's up on GitHub if you want to poke at it or tell me what's wrong with my impulse math: https://github.com/whomi928/Physics-Engine.git

LinkedIn: www.linkedin.com/in/shaurya-aditya-0563a0377

Top comments (0)