2.2 Trigonometry & Geometry

Phase 2 · Game Math · Study time: 30–50 h

Sine and cosine for angles and circular motion, plus lines, planes, rays, spheres and boxes with their intersection tests — the basis of aiming, collision and picking objects.

Every object in a game has a position, and most of them also have a direction: which way a character faces, which way a bullet flies, which way a camera looks. Turning "which way" into numbers is trigonometry (the math of angles and circles). This chapter also covers the basic shapes games use for collision: circles, boxes, rays, and the tests that ask "did these two things touch?" You already know C++ basics and how to reason about data — this chapter is smaller code, more math, but the same pattern: code, real output, plain explanation.

Type the examples in and run them yourself. Trig bugs are usually invisible until you see the wrong number on screen, so getting comfortable with the real output now will save you real debugging time later.

1. Radians vs degrees: the two ways to measure an angle

An angle can be measured in degrees (a full circle is 360) or in radians (a full circle is 2*pi, about 6.28318). Degrees are what humans use ("turn 90 degrees"). Radians are what almost every math library uses internally, including C++'s <cmath> functions like sin, cos, and atan2. If you pass degrees where radians are expected, you get a wrong answer with no error or warning — the function still returns a number, just the wrong one.

degrees: 0 90 180 270 360 radians: 0 pi/2 pi 3pi/2 2pi | | | | | +-----+------+------+------+ a quarter turn = pi/2 radians = 90 degrees a full turn = 2*pi radians = 360 degrees

The conversion is a straight ratio: 360 degrees equals 2*pi radians, so 180 degrees equals pi radians. That gives two formulas:

radians = degrees * (PI / 180.0)
degrees = radians * (180.0 / PI)
#include <iostream>

const double PI = 3.14159265358979323846;   // C++ has no standard PI constant,
                                             // so define your own once, here
double toRadians(double degrees) { return degrees * PI / 180.0; }
double toDegrees(double radians) { return radians * 180.0 / PI; }

int main() {
    double degs[] = {0, 45, 90, 180, 270, 360};
    for (double d : degs)
        std::cout << d << " degrees = " << toRadians(d) << " radians\n";
}

Output:

0 degrees = 0 radians
45 degrees = 0.785398 radians
90 degrees = 1.5708 radians
180 degrees = 3.14159 radians
270 degrees = 4.71239 radians
360 degrees = 6.28319 radians

Check the pattern: 90 degrees became about 1.57, which is pi/2 (3.14159 / 2). 180 degrees became 3.14159, which is exactly pi. That is the whole relationship — degrees and radians are just two different rulers for measuring the same turn.

Tip C++ does not standardize a PI constant you can just use (some platforms have M_PI from <cmath>, but it is not guaranteed everywhere). Most game codebases define their own constant once, like the line above, and reuse it everywhere instead of retyping digits of pi.

2. sin, cos, tan, and the unit circle

You may have learned sine, cosine, and tangent from a right triangle: for an angle theta (theta) inside the triangle, sin(theta) = opposite / hypotenuse, cos(theta) = adjacent / hypotenuse, and tan(theta) = opposite / adjacent (the mnemonic SOH-CAH-TOA). That definition is correct but clunky for games, because game code rarely has a triangle lying around — it has an angle and wants a direction.

The unit circle is a circle of radius 1 centered at the origin (0,0). Picture standing at the center and turning by an angle theta, measured counter-clockwise from the positive x-axis. Walk out to the edge of the circle. The point you land on is always (cos(theta), sin(theta)). That is the entire trick: cos gives the x-coordinate, sin gives the y-coordinate, for a point exactly 1 unit from the center, at angle theta.

y | | P (cos theta, sin theta) | / | / | / | / theta ----------------+--------------- x | (unit circle, radius = 1, centered at origin) theta is measured counter-clockwise from the +x axis

Why does this match the triangle definition? Drop a straight line from point P down to the x-axis. That makes a right triangle where the hypotenuse is the radius (length 1), the adjacent side has length cos(theta), and the opposite side has length sin(theta). Because the hypotenuse is exactly 1, the fractions "opposite/hypotenuse" and "adjacent/hypotenuse" simplify to just the opposite and adjacent lengths themselves. Same math, simpler picture.

#include <iostream>
#include <cmath>
#include <iomanip>

const double PI = 3.14159265358979323846;

int main() {
    std::cout << std::fixed << std::setprecision(3);
    double anglesDeg[] = {0, 30, 45, 60};
    for (double d : anglesDeg) {
        double rad = d * PI / 180.0;
        std::cout << "deg=" << d
                  << " cos=" << std::cos(rad)
                  << " sin=" << std::sin(rad)
                  << " tan=" << std::tan(rad) << "\n";
    }
}

Output:

deg=0 cos=1.000 sin=0.000 tan=0.000
deg=30 cos=0.866 sin=0.500 tan=0.577
deg=45 cos=0.707 sin=0.707 tan=1.000
deg=60 cos=0.500 sin=0.866 tan=1.732

Read the 45-degree row: cos=0.707 and sin=0.707 — equal, because at 45 degrees you have walked exactly halfway between "all x" and "all y". And tan is just sin/cos at every row (check 60 degrees: 0.866 / 0.500 = 1.732) — tangent is the ratio of the other two, not a separate idea.

Common mistake Expecting an exact 0 from trig functions at "clean" angles. Floating point cannot represent pi exactly, so cos of a 90-degree angle is not precisely zero:
#include <iostream>
#include <cmath>

int main() {
    double rad = 1.5707963267948966;   // 90 degrees in radians
    double c = std::cos(rad);
    double t = std::tan(rad);
    std::cout << "cos(90) = " << c << "\n";
    std::cout << "tan(90) = " << t << "\n";
    std::cout << (c == 0.0 ? "exactly zero" : "NOT exactly zero") << "\n";
}

Output:

cos(90) = 6.12323e-17
tan(90) = 1.63312e+16
NOT exactly zero

cos(90 degrees) should mathematically be 0, but it prints 6.12323e-17 — a number so tiny it is meaningless, yet not the same bit pattern as 0.0. Because tan = sin/cos, dividing by that almost-zero number blows up into a huge, meaningless value instead of the "undefined" you would expect. The lesson: never compare a trig result to a value with ==. Compare against a small tolerance instead, like std::fabs(c) < 0.0001.

3. Moving in a circle with sin and cos

Once you can turn an angle into a point on the unit circle, moving something in a circle is one line: scale the unit-circle point by the circle's radius, and shift it by the circle's center.

x = centerX + radius * cos(angle) y = centerY + radius * sin(angle) increase "angle" a little every frame -> the point sweeps around the circle
#include <iostream>
#include <cmath>
#include <iomanip>

const double PI = 3.14159265358979323846;

int main() {
    std::cout << std::fixed << std::setprecision(3);
    double radius = 5.0;
    int steps = 5;
    for (int i = 0; i < steps; i++) {
        double angle = (2.0 * PI / steps) * i;   // spread evenly around the circle
        double x = radius * std::cos(angle);
        double y = radius * std::sin(angle);
        std::cout << "step " << i << ": angle=" << angle
                  << " pos=(" << x << ", " << y << ")\n";
    }
}

Output:

step 0: angle=0.000 pos=(5.000, 0.000)
step 1: angle=1.257 pos=(1.545, 4.755)
step 2: angle=2.513 pos=(-4.045, 2.939)
step 3: angle=3.770 pos=(-4.045, -2.939)
step 4: angle=5.027 pos=(1.545, -4.755)

Five steps, evenly spaced around a circle of radius 5, all centered on the origin. Notice the points form a five-pointed pattern around (0,0), each one exactly 5 units from the center (you can check: 1.545^2 + 4.755^2 works out to about 25, which is radius^2). In a real game, "angle" would grow a little bit every frame (angle += turnSpeed * deltaTime) instead of jumping in five fixed steps — that is how you animate an orbiting satellite, a sweeping radar line, or the hands of a clock.

Tip This same formula is how you place things evenly around a circle without any motion at all — spawn points around an arena, icons around a radial menu, enemies surrounding a boss. Just compute angle = (2*PI / count) * i for each item i and you get an evenly spaced ring for free.

4. Rotating a 2D vector by an angle

Section 3 rotated a point around the origin using an ever-changing angle. Now the more general tool: given any 2D vector (x, y) and an angle, produce the same vector turned by that angle. This is called a rotation, and the formula is:

new_x = x * cos(angle) - y * sin(angle) new_y = x * sin(angle) + y * cos(angle)

You do not need to derive this to use it — treat it as a recipe, the same way you treat sqrt as a recipe. It takes a vector and spins it around (0,0) by angle, counter-clockwise for a positive angle.

#include <iostream>
#include <cmath>
#include <iomanip>

const double PI = 3.14159265358979323846;

struct Vec2 { double x, y; };

Vec2 rotate(Vec2 v, double angleRad) {
    double c = std::cos(angleRad);
    double s = std::sin(angleRad);
    return { v.x * c - v.y * s,    // new x
             v.x * s + v.y * c };  // new y
}

int main() {
    std::cout << std::fixed << std::setprecision(3);
    Vec2 v = {2.0, 0.0};
    double anglesDeg[] = {0, 90, 180, 270};
    for (double deg : anglesDeg) {
        Vec2 r = rotate(v, deg * PI / 180.0);
        std::cout << deg << " deg: (" << r.x << ", " << r.y << ")\n";
    }
}

Output:

0 deg: (2.000, 0.000)
90 deg: (0.000, 2.000)
180 deg: (-2.000, 0.000)
270 deg: (0.000, -2.000)

Start with the vector pointing right, (2, 0). Rotate it 90 degrees and it points straight up, (0, 2). Another 90 and it points left, (-2, 0). Another 90 and it points down, (0, -2). It walked around a full circle in four quarter-turns, always keeping its length (2 units).

(0,2) | | (-2,0) -----+----- (2,0) start here, angle = 0 | | (0,-2) rotating by +90 each time walks: right -> up -> left -> down
Common mistake Assuming "positive angle = clockwise" or vice versa without checking your coordinate system. The formula above rotates counter-clockwise when y points up (math convention, and Unity's 2D world space). But screen-space pixel coordinates usually have y pointing down — in that system the exact same formula visually rotates clockwise instead. Neither is wrong; you just have to know which coordinate system you are drawing in.

5. Aiming at a target: atan2 and the direction vector

Sections 3 and 4 went from an angle to a position or a rotated vector. Aiming is the reverse problem: you have two positions — the shooter and the target — and you want the angle between them. The tool for that is atan2(dy, dx), which takes a y difference and an x difference and returns the angle of the vector (dx, dy), measured the same way as the unit circle in section 2.

y | | * target (tx, ty) | / | / | / angle = atan2(dy, dx) | / *---------+----------------- x shooter (px,py) dx = tx-px, dy = ty-py
#include <iostream>
#include <cmath>
#include <iomanip>

const double PI = 3.14159265358979323846;
double toDegrees(double rad) { return rad * 180.0 / PI; }

int main() {
    std::cout << std::fixed << std::setprecision(2);
    struct { double dx, dy; } targets[] = {
        {5, 0}, {0, 5}, {-5, 0}, {0, -5}, {3, 3}
    };
    for (auto t : targets) {
        double angle = std::atan2(t.dy, t.dx);
        std::cout << "dx=" << t.dx << " dy=" << t.dy
                  << " -> angle=" << toDegrees(angle) << " deg\n";
    }
}

Output:

dx=5.00 dy=0.00 -> angle=0.00 deg
dx=0.00 dy=5.00 -> angle=90.00 deg
dx=-5.00 dy=0.00 -> angle=180.00 deg
dx=0.00 dy=-5.00 -> angle=-90.00 deg
dx=3.00 dy=3.00 -> angle=45.00 deg

Every result matches the unit circle picture: a target straight right is 0 degrees, straight up is 90, straight left is 180, straight down is -90 (negative, since it turned the other way), and diagonally up-right is 45.

Common mistake Computing the angle as atan(dy / dx) instead of atan2(dy, dx). Two problems: dividing by dx = 0 (a target directly above or below) crashes or gives infinity, and plain atan cannot tell a target in front from a target directly behind — atan(1/1) and atan(-1/-1) both equal the same angle, even though the two targets are in opposite directions. atan2 looks at the sign of both dx and dy separately, so it always returns the correct full-circle angle.

Once you have the angle, section 2's trick turns it back into a direction: (cos(angle), sin(angle)) is a unit vector (length exactly 1) pointing straight at the target. Scale that by a speed and you get a velocity that homes in on the target — this is exactly how a simple homing bullet or a "look at player" rotation works.

#include <iostream>
#include <cmath>
#include <iomanip>

int main() {
    std::cout << std::fixed << std::setprecision(2);

    double px = 0, py = 0;      // bullet start position
    double tx = 8, ty = 6;      // target position (does not move)
    double speed = 5.0;         // units per step

    double dx = tx - px;
    double dy = ty - py;
    double angle = std::atan2(dy, dx);     // angle from bullet to target

    double vx = std::cos(angle) * speed;   // velocity x
    double vy = std::sin(angle) * speed;   // velocity y

    std::cout << "aim angle = " << angle << " rad\n";
    for (int step = 1; step <= 3; step++) {
        px += vx;
        py += vy;
        std::cout << "step " << step << ": pos=(" << px << ", " << py << ")\n";
    }
}

Output:

aim angle = 0.64 rad
step 1: pos=(4.00, 3.00)
step 2: pos=(8.00, 6.00)
step 3: pos=(12.00, 9.00)

The target is at (8, 6), which happens to be exactly 10 units away (an 6-8-10 right triangle, like the familiar 3-4-5 one but doubled). At speed 5, the bullet covers that distance in exactly 2 steps, landing precisely on (8, 6) — then keeps going past it on step 3, since nothing tells it to stop. A real homing bullet would recompute angle every frame toward the target's current position, and check the remaining distance so it can stop or explode on arrival.

6. Lines, rays, and planes

Before the collision tests, you need the shapes those tests work on. Three "infinite" shapes show up constantly:

LINE (both directions go on forever): <----------P---D----------> RAY (starts at a point, one direction goes on forever): origin *----------D---------> PLANE (a flat surface, described by a point and a normal): --------------------------------- <- the plane ^ | n (normal: perpendicular to the surface)

A ray is written as a formula: pick any number t >= 0, and the point at "distance" t along the ray is origin + t * direction. Larger t means further along the ray. This t shows up again in every intersection test later in this chapter — it is always "how far along the ray did we hit something."

#include <iostream>

struct Vec2 { double x, y; };

Vec2 pointOnRay(Vec2 origin, Vec2 dir, double t) {
    return { origin.x + t * dir.x, origin.y + t * dir.y };
}

int main() {
    Vec2 origin = {0, 0};
    Vec2 dir    = {1, 0.5};    // the direction the ray travels

    for (double t = 0; t <= 3; t += 1.0) {
        Vec2 p = pointOnRay(origin, dir, t);
        std::cout << "t=" << t << " -> point=(" << p.x << ", " << p.y << ")\n";
    }
}

Output:

t=0 -> point=(0, 0)
t=1 -> point=(1, 0.5)
t=2 -> point=(2, 1)
t=3 -> point=(3, 1.5)
Tip Here dir has length sqrt(1^2 + 0.5^2) ~= 1.118, not exactly 1. That means t is not the same as real distance travelled — at t=1 the point moved 1.118 units, not 1. Almost every intersection formula in this chapter assumes dir is a unit vector (length exactly 1), so that t directly equals distance. Normalize your ray direction (divide it by its own length) before feeding it into a collision test.

Now the dot product — a small tool you will use repeatedly from here on. For two vectors a and b, dot(a, b) = a.x*b.x + a.y*b.y. It is one number, not a vector, and its sign tells you how the two vectors point relative to each other.

#include <iostream>

struct Vec2 { double x, y; };

double dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

int main() {
    Vec2 right     = {1, 0};
    Vec2 up        = {0, 1};
    Vec2 alsoRight = {5, 0};
    Vec2 leftish   = {-1, 0.2};

    std::cout << "right . up = "        << dot(right, up)        << "\n";
    std::cout << "right . alsoRight = " << dot(right, alsoRight) << "\n";
    std::cout << "right . leftish = "   << dot(right, leftish)   << "\n";
}

Output:

right . up = 0
right . alsoRight = 5
right . leftish = -1

right and up are perpendicular (90 degrees apart) and their dot product is exactly 0 — that is a general rule: dot product is 0 when two vectors are perpendicular. right and alsoRight point the same way, and the dot product came out positive. right and leftish point mostly opposite ways, and it came out negative. Positive means "generally the same direction", negative means "generally opposite", zero means "perpendicular".

That sign trick is exactly how a plane's signed distance works: for a plane with normal n passing through point Q, a point P's signed distance from the plane is dot(n, P - Q). Zero means P is on the plane, positive means P is on the side the normal points toward, negative means the other side.

#include <iostream>

struct Vec2 { double x, y; };
double dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

int main() {
    Vec2 pointOnPlane = {0, 0};   // the ground, at y=0
    Vec2 normal       = {0, 1};   // "up" is the positive side

    Vec2 above = {3, 5};
    Vec2 below = {3, -2};

    Vec2 toAbove = { above.x - pointOnPlane.x, above.y - pointOnPlane.y };
    Vec2 toBelow = { below.x - pointOnPlane.x, below.y - pointOnPlane.y };

    std::cout << "above: signed dist = " << dot(normal, toAbove) << "\n";
    std::cout << "below: signed dist = " << dot(normal, toBelow) << "\n";
}

Output:

above: signed dist = 5
below: signed dist = -2

The point (3, 5) is 5 units above the ground plane, and the formula reports +5. The point (3, -2) is 2 units below, and it reports -2. In 3D this exact formula (with a 3D normal and a 3D point) is how engines test which side of the ground, a wall, or a camera frustum face something is on.

7. Circles, spheres, and axis-aligned boxes (AABB)

Checking whether two detailed character meshes overlap, triangle by triangle, is expensive. Games almost never do that directly. Instead they wrap each object in a simple bounding shape and test that instead — cheap, approximate, and good enough for most gameplay. The two simplest bounding shapes are the circle (sphere in 3D) and the box.

A circle needs just a center and a radius. In 3D the same idea is called a sphere. An AABB (axis-aligned bounding box) is a rectangle (or, in 3D, a box) whose edges are always parallel to the x and y axes — it never rotates. That restriction is what makes it cheap: you only ever need to store two corners, the minimum corner and the maximum corner.

CIRCLE: AABB (axis-aligned box): (minX,minY) (maxX,minY) * * * *--------------------* * * | | * center * | | * (cx,cy) * radius | | * * *--------------------* * * * (minX,maxY) (maxX,maxY)
#include <iostream>

struct Vec2 { double x, y; };

struct Circle {
    Vec2 center;
    double radius;
};

struct AABB {              // Axis-Aligned Bounding Box
    Vec2 min;                // corner with the smallest x and y
    Vec2 max;                // corner with the largest x and y
};

int main() {
    Circle c = { {10.0, 10.0}, 3.0 };
    AABB box = { {0.0, 0.0}, {4.0, 2.0} };

    std::cout << "circle: center=(" << c.center.x << "," << c.center.y
              << ") radius=" << c.radius << "\n";

    double width  = box.max.x - box.min.x;
    double height = box.max.y - box.min.y;
    std::cout << "box: width=" << width << " height=" << height << "\n";
}

Output:

circle: center=(10,10) radius=3
box: width=4 height=2

Nothing fancy: the circle stores three numbers, the box stores four (two corners), and both let you recover any other useful measurement (width, height, diameter) with simple subtraction.

Tip Games usually run cheap bounding-shape checks first (circle vs circle, or AABB vs AABB) to quickly rule out pairs of objects that obviously cannot be touching, and only run expensive, exact checks on the few pairs that pass. This two-step pattern is called broad phase then narrow phase collision detection, and it is why every physics engine leans so heavily on the simple shapes in this section.

8. Point-in-circle and point-in-rectangle tests

The simplest collision question is "is this single point inside this shape?" — used for mouse clicks, spawn checks, and trigger zones. For a circle, a point is inside when its distance to the center is less than or equal to the radius.

* * * * * * C * * (cx,cy) * * r * * P_in(o) * P_in: distance(P_in, C) <= r -> INSIDE * * * * * P_out(o) distance(P_out, C) > r -> OUTSIDE
#include <iostream>

struct Vec2 { double x, y; };

bool pointInCircle(Vec2 p, Vec2 center, double radius) {
    double dx = p.x - center.x;
    double dy = p.y - center.y;
    double distSq = dx*dx + dy*dy;         // squared distance -- no sqrt needed
    return distSq <= radius * radius;
}

int main() {
    Vec2 center = {0, 0};
    double radius = 5.0;

    Vec2 inside  = {3, 4};   // distance = 5 exactly (a 3-4-5 triangle)
    Vec2 outside = {5, 5};   // distance = sqrt(50) ~= 7.07

    std::cout << "inside point in circle? "  << pointInCircle(inside, center, radius)  << "\n";
    std::cout << "outside point in circle? " << pointInCircle(outside, center, radius) << "\n";
}

Output:

inside point in circle? 1
outside point in circle? 0

(3, 4) is exactly 5 units from the origin (3-4-5 right triangle), which is exactly the radius — the boundary counts as "inside" here because the test uses <=. (5, 5) is about 7.07 units away, further than the radius, so it fails. std::cout prints a bool as 1 for true and 0 for false.

Tip The code compares distSq <= radius*radius instead of sqrt(distSq) <= radius. Both give the same true/false answer, because squaring preserves order for non-negative numbers, but the squared version skips a sqrt call. It looks like a tiny saving, but when you are checking hundreds of objects every frame, skipping thousands of unnecessary square roots adds up. Reach for the squared-distance version by habit.

A point-in-rectangle (AABB) test is even simpler: check that the point's x is between the box's min and max x, and separately that its y is between the box's min and max y.

(minX,minY) *------------------* (maxX,minY) | | | P_in | | | (minX,maxY) *------------------* (maxX,maxY) P_out (x > maxX -> outside)
#include <iostream>

struct Vec2 { double x, y; };
struct AABB { Vec2 min, max; };

bool pointInAABB(Vec2 p, AABB box) {
    return p.x >= box.min.x && p.x <= box.max.x
        && p.y >= box.min.y && p.y <= box.max.y;
}

int main() {
    AABB box = { {0, 0}, {10, 5} };

    Vec2 inside  = {4, 3};
    Vec2 outside = {12, 3};

    std::cout << "inside point in box? "  << pointInAABB(inside, box)  << "\n";
    std::cout << "outside point in box? " << pointInAABB(outside, box) << "\n";
}

Output:

inside point in box? 1
outside point in box? 0

Four comparisons, all "and"-ed together with &&. If any one of them fails, the point is outside. This is the cheapest intersection test in the whole chapter, and it is also the core building block AABB-vs-AABB collision reuses in the next section's cousin (box overlap tests just do this same min/max comparison on both boxes instead of a box and a point).

9. Distance from a point to a line

Sometimes you do not need "inside or outside", just "how far is this point from that line" — useful for things like keeping an object a minimum distance from a wall, or checking how close a shot passed to a target. This needs one more small tool: the 2D cross product. Unlike the 3D cross product (which returns a vector), the 2D version returns a single number: cross(a, b) = a.x*b.y - a.y*b.x. Its size relates to the area of the parallelogram the two vectors would form, and its sign tells you which side b is on relative to a.

P | | d (perpendicular distance) | A ----------------+----------------- B foot of the perpendicular
#include <iostream>
#include <cmath>

struct Vec2 { double x, y; };

double distancePointToLine(Vec2 p, Vec2 a, Vec2 b) {
    Vec2 d  = { b.x - a.x, b.y - a.y };      // line direction (A to B)
    Vec2 ap = { p.x - a.x, p.y - a.y };      // vector from A to P
    double cross = d.x * ap.y - d.y * ap.x;  // 2D cross product (a single number)
    double lenD = std::sqrt(d.x*d.x + d.y*d.y);
    return std::fabs(cross) / lenD;
}

int main() {
    Vec2 a = {0, 0};
    Vec2 b = {10, 0};       // a horizontal line along the x-axis
    Vec2 p = {4, 3};

    std::cout << "distance = " << distancePointToLine(p, a, b) << "\n";
}

Output:

distance = 3

You can sanity-check this one by eye: the line runs along y = 0, and the point is at y = 3, so the perpendicular distance is obviously 3. The formula agrees: cross = 10*3 - 0*4 = 30, the line's length lenD = 10, and 30 / 10 = 3. The cross product measures the area of the parallelogram formed by the line's direction and the vector to the point; dividing by the line's length converts that area into a plain perpendicular distance.

Tip Drop the std::fabs and the sign of the cross product tells you which side of the line the point is on — positive on one side, negative on the other. That single idea (the sign of a 2D cross product) is how triangle rasterizers decide which pixels are inside a triangle, and how simple 2D collision code decides which side of a wall you are standing on.

10. Ray vs sphere (circle) intersection

This test answers "does this ray hit this circle, and if so, how far along the ray?" — the exact question a hitscan weapon, a mouse-picking ray, or a line-of-sight check needs answered. The geometric idea: find the point on the ray line closest to the circle's center, and see if that closest point is within the radius.

* * * * * * C * O ----------tca----* (center) * (origin) ------dir---> * * * * t0 * t1 * * * tca = how far along the ray the closest approach to C is t0, t1 = the two points where the ray line crosses the circle
#include <iostream>
#include <cmath>

struct Vec2 { double x, y; };

double dot(Vec2 a, Vec2 b) { return a.x*b.x + a.y*b.y; }

bool raySphere(Vec2 origin, Vec2 dir, Vec2 center, double radius, double& tHit) {
    Vec2 L = { center.x - origin.x, center.y - origin.y };
    double tca = dot(L, dir);              // closest-approach distance along the ray
    double d2 = dot(L, L) - tca * tca;     // squared distance from center to the ray line
    double r2 = radius * radius;
    if (d2 > r2) return false;             // ray line misses the circle entirely

    double thc = std::sqrt(r2 - d2);       // half-chord length
    double t0 = tca - thc;                 // near hit
    double t1 = tca + thc;                 // far hit

    if (t0 >= 0) { tHit = t0; return true; }
    if (t1 >= 0) { tHit = t1; return true; }
    return false;                          // circle is entirely behind the ray's origin
}

int main() {
    Vec2 origin = {0, 0};
    Vec2 center = {10, 0};
    double radius = 2.0;

    Vec2 dir1 = {1, 0};    // points straight at the circle, unit length
    Vec2 dir2 = {0, 1};    // points straight up, away from the circle

    double t;
    if (raySphere(origin, dir1, center, radius, t))
        std::cout << "dir1: hit at t=" << t << "\n";
    else
        std::cout << "dir1: no hit\n";

    if (raySphere(origin, dir2, center, radius, t))
        std::cout << "dir2: hit at t=" << t << "\n";
    else
        std::cout << "dir2: no hit\n";
}

Output:

dir1: hit at t=8
dir2: no hit

The circle is centered at (10, 0) with radius 2, so it spans from x=8 to x=12 along the x-axis. The first ray fires straight along +x from the origin and, as expected, first touches the circle at x=8, which is t=8 steps along a unit-length direction. The second ray fires straight up — it never gets anywhere near x=10, so it misses no matter how far it travels, and the function correctly reports no hit.

Common mistake Passing a dir that is not a unit vector. If dir has length 2, every "distance" the formula computes is off by that same factor, and tHit stops meaning "distance to the hit point" — it still tells you a hit happened, but the reported number is wrong. Always normalize the ray direction before calling a test like this.

11. Ray vs AABB: the slab test

The last test: does a ray hit an axis-aligned box? The classic method is called the slab test. Picture the box's x-range as a "slab" — an infinite strip between minX and maxX. Compute the range of t values where the ray is inside that strip. Do the same for the y-range. The ray actually hits the box only where both ranges overlap.

y | 5 --+----*-------*---- <- box top (maxY) | | BOX | 1 --+----*-------*---- <- box bottom (minY) | 4 8 +----+-------+-------- x (minX) (maxX) ray from (0,3) heading +x: enters the x-slab at t=4, and y=3 is inside the y-range the whole time -> HIT at t=4
#include <iostream>
#include <algorithm>
#include <limits>

struct Vec2 { double x, y; };
struct AABB { Vec2 min, max; };

bool raySlabAABB(Vec2 origin, Vec2 dir, AABB box, double& tHit) {
    double tMin = -std::numeric_limits<double>::infinity();
    double tMax =  std::numeric_limits<double>::infinity();

    // X axis slab
    if (dir.x != 0.0) {
        double t1 = (box.min.x - origin.x) / dir.x;
        double t2 = (box.max.x - origin.x) / dir.x;
        if (t1 > t2) std::swap(t1, t2);
        tMin = std::max(tMin, t1);
        tMax = std::min(tMax, t2);
    } else if (origin.x < box.min.x || origin.x > box.max.x) {
        return false;   // parallel to the x-slab and outside it -- can never hit
    }

    // Y axis slab
    if (dir.y != 0.0) {
        double t1 = (box.min.y - origin.y) / dir.y;
        double t2 = (box.max.y - origin.y) / dir.y;
        if (t1 > t2) std::swap(t1, t2);
        tMin = std::max(tMin, t1);
        tMax = std::min(tMax, t2);
    } else if (origin.y < box.min.y || origin.y > box.max.y) {
        return false;
    }

    if (tMin > tMax || tMax < 0) return false;  // slabs don't overlap, or box is behind us
    tHit = tMin;
    return true;
}

int main() {
    AABB box = { {4, 1}, {8, 5} };

    Vec2 origin1 = {0, 3};
    Vec2 dir1    = {1, 0};    // straight along +x, at y=3 (inside the box's y-range)

    Vec2 origin2 = {0, 3};
    Vec2 dir2    = {0, 1};    // straight up -- x never reaches the box

    double t;
    if (raySlabAABB(origin1, dir1, box, t))
        std::cout << "ray1: hit at t=" << t << "\n";
    else
        std::cout << "ray1: no hit\n";

    if (raySlabAABB(origin2, dir2, box, t))
        std::cout << "ray2: hit at t=" << t << "\n";
    else
        std::cout << "ray2: no hit\n";
}

Output:

ray1: hit at t=4
ray2: no hit

Ray 1 starts at (0, 3) and moves along +x. Its y never changes, and 3 is already inside the box's y-range [1, 5], so the y-slab places no extra restriction. Its x-slab check finds it enters the box's x-range [4, 8] at t=4 — that becomes the answer. Ray 2 moves straight up from the same start; its x stays 0 forever, which is outside the box's x-range [4, 8] no matter how far it travels, so the very first check (dir.x == 0 and origin.x outside the slab) rejects it immediately.

Common mistake Dividing by dir.x or dir.y without checking for zero first. A ray that travels perfectly horizontally has dir.y == 0, and dividing by zero is undefined behavior for integers and produces inf/nan for floating point — either way it silently corrupts the rest of the calculation. The code above handles that axis as a special case: if the ray does not move along an axis at all, it either always stays in that slab (origin is already between min and max) or never does, and no division is needed either way.

12. Glossary

13. Exercises

Exercise 1 (a) Convert 135 degrees to radians. (b) Using the rotate() formula from section 4, compute the result of rotating the vector (1, 0) by 135 degrees. Round your answer to 3 decimal places. You may use these known values: cos(135deg) = -0.707107, sin(135deg) = 0.707107.
Show answer

(a) radians = degrees * PI / 180 = 135 * 3.14159265 / 180 ~= 2.356 radians (this is 3*PI/4).

(b) Using new_x = x*cos(a) - y*sin(a) and new_y = x*sin(a) + y*cos(a) with x=1, y=0:

new_x = 1 * (-0.707107) - 0 * 0.707107 = -0.707
new_y = 1 * 0.707107 + 0 * (-0.707107) = 0.707

So (1, 0) rotated by 135 degrees is approximately (-0.707, 0.707) — pointing up and to the left, which matches intuition: 135 degrees is past straight up (90) and partway to straight left (180).

Exercise 2 A circle has center (2, 3) and radius 4. A rectangle (AABB) has corners min=(0, 0) and max=(5, 5). For the point (5, 5): is it inside the circle? Is it inside the rectangle? Show your work using the formulas from section 8.
Show answer

Circle test: dx = 5 - 2 = 3, dy = 5 - 3 = 2, distSq = 3*3 + 2*2 = 9 + 4 = 13. radius*radius = 4*4 = 16. Since 13 <= 16, the point is inside the circle.

AABB test: is 5 >= 0 && 5 <= 5? Yes (5 is on the boundary, which counts). Is 5 >= 0 && 5 <= 5 for y too? Yes. So the point is inside the AABB as well (touching its top-right corner exactly).

(5, 5) passes both tests, sitting inside the circle with a little room to spare and exactly on the corner of the box.

Exercise 3 An AABB has min=(2, 2) and max=(6, 6). A ray starts at origin=(0, 0) with direction dir=(1, 1) (not unit length, but that is fine for this exercise). Using the slab test from section 11, compute the x-slab range, the y-slab range, and say whether the ray hits the box and at what t.
Show answer

X slab: t1 = (2 - 0) / 1 = 2, t2 = (6 - 0) / 1 = 6. Range: [2, 6].

Y slab: same numbers by symmetry: t1 = (2 - 0) / 1 = 2, t2 = (6 - 0) / 1 = 6. Range: [2, 6].

Overlap: tMin = max(2, 2) = 2, tMax = min(6, 6) = 6. Since tMin <= tMax and tMax >= 0, it is a hit, at t = 2.

Sanity check: the hit point is origin + t*dir = (0 + 2*1, 0 + 2*1) = (2, 2) — exactly the box's near corner. That makes sense: the ray direction (1,1) points at a perfect 45 degrees, heading straight for the corner closest to the origin. (Note: because dir is not unit length here, t does not equal the true travelled distance, but the hit/miss result and the hit point are still correct.)

That covers the math a lot of gameplay code quietly leans on. Angles convert between degrees and radians with one formula; the unit circle turns any angle into a direction via sin/cos; atan2 turns any two positions into an aiming angle; and a handful of cheap shapes — circles, AABBs, rays — cover most of the collision questions a game asks every single frame. None of this needs to be memorized word for word; what matters is recognizing the shape of the problem ("I have two positions and need an angle" -> atan2; "I have a ray and a box" -> slab test) and knowing where to reach.

← Back to all chapters