Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C++  Module 22 – C++ for game development

Game maths in C++: vectors and matrices

Write Vec2 and Mat3 value types in C++, use dot and cross products for view cones and bounces, and combine rotation, scale and translation in order.

  • Intermediate
  • 20 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 7 of 20 minutes free
Free: Vectors are small values with operators · The dot product measures agreement, the cross product picks a side · Matrices move, turn and stretch points, and their order matters · Practise: a Vec3 for 3D. With a pass: 2 in-depth parts and 3 interview answers, about 13 minutes.

What you will learn

  • Implement 2D and 3D vector types with operators
  • Use the dot and cross products to test angles, sides and bounces
  • Apply rotation, scale and translation with 3×3 matrices, in a fixed order
  • Avoid common floating-point pitfalls in game maths

Before you start

Is an enemy at (5, 1) inside a guard’s view of 30° to either side, and on the guard’s left or right? Game code answers such questions with vectors, which hold positions, velocities and directions, and matrices, which move, turn and stretch them. Written as small C++ structs with operators, a + v * dt reads like the formula it computes.

The dot product says how much two directions agree; the cross product gives a perpendicular, whose sign tells left from right. A 3×3 matrix holds a 2D rotation, scale and translation at once, and matrices combine in a fixed order. Floats round at every step: positions are compared with a tolerance, never with ==, a vector turned over and over drifts, and far from the origin small steps vanish. You will build these types and use them on a guard and a bounce.

Vectors are small values with operators

vec.h defines Vec2 as an aggregate: A class type with no user-declared constructors, only public non-static data members, no virtual functions and no virtual, private or protected base classes, or an array. A brace list fills it member by member, in declaration order, without calling a constructor. More in “C++ structs, aggregates and designated initialisers” of two floats, half the memory of double. The operators are free constexpr functions that take their arguments by value: an 8-byte struct is as cheap to copy as a pointer, and constant vectors can be computed while compiling.

vec.h C header · vec.h
// A 2D vector for game maths: a small value with operators, so code reads like the formulas.
#pragma once
#include <cmath>
#include <format>
#include <string>

struct Vec2 {
    float x = 0.0f;
    float y = 0.0f;
};

constexpr Vec2 operator+(Vec2 a, Vec2 b) { return {a.x + b.x, a.y + b.y}; }
constexpr Vec2 operator-(Vec2 a, Vec2 b) { return {a.x - b.x, a.y - b.y}; }
constexpr Vec2 operator-(Vec2 v) { return {-v.x, -v.y}; }
constexpr Vec2 operator*(Vec2 v, float s) { return {v.x * s, v.y * s}; }
constexpr Vec2 operator*(float s, Vec2 v) { return v * s; }
constexpr Vec2 operator/(Vec2 v, float s) { return {v.x / s, v.y / s}; }
constexpr Vec2& operator+=(Vec2& a, Vec2 b) { return a = a + b; }

// |a| |b| cos(angle between a and b): positive when they point the same way, 0 when perpendicular.
constexpr float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }

// The z part of the 3D cross product: positive when b is anticlockwise from a, with y pointing up.
constexpr float cross(Vec2 a, Vec2 b) { return a.x * b.y - a.y * b.x; }

inline float length(Vec2 v) { return std::sqrt(dot(v, v)); }

// The unit vector pointing like v. A zero vector has no direction, so it stays zero.
inline Vec2 normalized(Vec2 v) {
    const float len = length(v);
    return len > 0.0f ? v / len : Vec2{};
}

// v after bouncing off a surface whose unit normal is n.
constexpr Vec2 reflect(Vec2 v, Vec2 n) { return v - 2.0f * dot(v, n) * n; }

inline std::string str(Vec2 v) { return std::format("({}, {})", v.x, v.y); }

normalized() returns the zero vector when its argument is zero: dividing by a length of 0 would produce NaN. values.cpp checks these claims on both compilers:

values.cpp C++ · values.cpp
// Vec2 as a value: how big it is, what the type traits say about it, a vector computed while
// compiling, and the zero vector that normalized() keeps from becoming NaN.
#include <print>
#include <type_traits>
#include "vec.h"

struct Vec2d {  // the same vector with double coordinates, for comparison
    double x = 0.0;
    double y = 0.0;
};

// Evaluated by the compiler: the build fails if this arithmetic is wrong.
constexpr Vec2 start{0.0f, 2.0f};
constexpr Vec2 velocity{3.0f, 4.0f};
constexpr Vec2 after_half_a_second = start + velocity * 0.5f;
static_assert(after_half_a_second.x == 1.5f && after_half_a_second.y == 4.0f);

int main() {
    std::println("sizeof(Vec2) = {}, sizeof(Vec2d) = {}, sizeof(void*) = {}", sizeof(Vec2), sizeof(Vec2d), sizeof(void*));
    std::println("aggregate: {}, trivially copyable: {}", std::is_aggregate_v<Vec2>, std::is_trivially_copyable_v<Vec2>);
    std::println("start + velocity * 0.5 = {}, checked while compiling", str(after_half_a_second));
    std::println("normalized of the zero vector: {}", str(normalized(Vec2{})));
}
Vec2 as a value, Apple clang and GCC 16 Python · values_session.py
from session import run

# values.cpp built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off values.cpp -o values && ./values
g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off values.cpp -o values-gcc && ./values-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off values.cpp -o values && ./values
sizeof(Vec2) = 8, sizeof(Vec2d) = 16, sizeof(void*) = 8
aggregate: true, trivially copyable: true
start + velocity * 0.5 = (1.5, 4), checked while compiling
normalized of the zero vector: (0, 0)
$ g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off values.cpp -o values-gcc && ./values-gcc
sizeof(Vec2) = 8, sizeof(Vec2d) = 16, sizeof(void*) = 8
aggregate: true, trivially copyable: true
start + velocity * 0.5 = (1.5, 4), checked while compiling
normalized of the zero vector: (0, 0)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 values_session.py

Vec2 takes 8 bytes, as a pointer does, against 16 for a double version. The type traits report an aggregate that is A type whose objects may be copied by copying their bytes, because its copy and move operations and its destructor are all the trivial ones the compiler writes., the static_assert passed, so the compiler itself computed (1.5, 4), and the zero vector stayed (0, 0).

Checkpoint

Check: Vectors as small values

  1. Question 1 of 2 Why do the operators of vec.h take their Vec2 arguments by value instead of by const reference?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A Vec2 is 8 bytes, as big as a pointer, so copying it costs no more than passing its address

    An 8-byte aggregate of two floats travels as cheaply as a pointer, and a copy can never alias anything else, so small value types such as Vec2 are passed by value.

  2. Question 2 of 2 An enemy has caught up with the player, so the vector between them is (0, 0). What does this normalize return for it?

    Read the code, then choose one answer.

    Vec2 normalize(Vec2 v) {
        const float len = std::sqrt(v.x * v.x + v.y * v.y);
        return {v.x / len, v.y / len};
    }
    Show the answer: Question 2 of 2

    Answer: (nan, nan): it divides 0 by 0, because it has no guard for the zero vector

    len is 0, so both coordinates are 0 / 0, which floating point turns into NaN. vec.h's normalized() returns the zero vector instead, as values.cpp printed: normalized of the zero vector: (0, 0).

The dot product measures agreement, the cross product picks a side

For two unit vectors, the dot product is the cosine of the angle between them: 1 when they point the same way, 0 when they are perpendicular, −1 when they are opposite. A guard who sees 30° to either side spots an enemy when dot(facing, normalized(to_enemy)) is at least cos 30° = 0.866, with no acos. The 2D cross product, a.x * b.y - a.y * b.x, is the sine of the angle times the lengths, so its sign says on which side b lies: positive means to the left when the y axis points up. With y pointing down, as in most screen coordinates, the sign flips.

The dot product also bounces things. With n the unit normal of a surface, dot(v, n) * n is the part of a velocity v that goes into the surface; a bounce flips that part and keeps the rest:

A velocity v = (3, −4) hits the floor, whose normal is n = (0, 1), and leaves as r = (3, 4).floorv = (3, −4)r = (3, 4)n = (0, 1)v · n = −4: the part (0, −4) flipsr = v − 2 (v · n) n = (3, 4)

Reflecting a velocity off the floor with the dot product

Text description of the diagram

A horizontal line is the floor, the line y = 0. An arrow labelled v = (3, −4) comes down from the upper left and ends at a point on the floor. A dashed arrow labelled n = (0, 1) points straight up from that point: the floor's unit normal. A second arrow, labelled r = (3, 4), leaves the same point towards the upper right, at the same angle to the normal as v.

Under the drawing: v · n = −4, so the part of v along n is (0, −4), and the bounce flips that part while the rest stays: r = v − 2 (v · n) n = (3, −4) − 2 · (−4) · (0, 1) = (3, 4).

dot_cross.cpp C++ · dot_cross.cpp
// A guard at the origin faces along +x and sees 30° to either side. Which enemies does it see,
// and on which side are they? Then a ball bounces off the floor and off a 45° wall.
#include <cmath>
#include <numbers>
#include <print>
#include "vec.h"

int main() {
    const Vec2 guard{0.0f, 0.0f};
    const Vec2 facing{1.0f, 0.0f};  // a unit vector
    const float cos_half_view = std::cos(30.0f * std::numbers::pi_v<float> / 180.0f);
    const Vec2 enemies[] = {{5.0f, 1.0f}, {2.0f, 4.0f}, {-3.0f, 0.5f}, {4.0f, -2.0f}};

    std::println("cos 30° = {:.4f}", cos_half_view);
    for (const Vec2 e : enemies) {
        const Vec2 to_enemy = normalized(e - guard);
        const float c = dot(facing, to_enemy);  // the cosine of the angle off the facing direction
        const char* seen = c >= cos_half_view ? "in view" : "not seen";
        const char* side = cross(facing, to_enemy) > 0.0f ? "left" : "right";
        std::println("enemy at {:<10} dot {:>7.4f}  {:<9} {}", str(e), c, seen, side);
    }

    const Vec2 v{3.0f, -4.0f};
    std::println("off the floor:    {}", str(reflect(v, Vec2{0.0f, 1.0f})));
    std::println("off the 45° wall: {}", str(reflect(v, normalized(Vec2{1.0f, 1.0f}))));
}

Built with -ffp-contract=off, both compilers print the same digits:

The guard's view and two bounces, both compilers Python · dot_cross_session.py
from session import run

# dot_cross.cpp (with vec.h) built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off dot_cross.cpp -o dot_cross && ./dot_cross
g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off dot_cross.cpp -o dot_cross-gcc && ./dot_cross-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off dot_cross.cpp -o dot_cross && ./dot_cross
cos 30° = 0.8660
enemy at (5, 1)     dot  0.9806  in view   left
enemy at (2, 4)     dot  0.4472  not seen  left
enemy at (-3, 0.5)  dot -0.9864  not seen  left
enemy at (4, -2)    dot  0.8944  in view   right
off the floor:    (3, 4)
off the 45° wall: (4, -3)
$ g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off dot_cross.cpp -o dot_cross-gcc && ./dot_cross-gcc
cos 30° = 0.8660
enemy at (5, 1)     dot  0.9806  in view   left
enemy at (2, 4)     dot  0.4472  not seen  left
enemy at (-3, 0.5)  dot -0.9864  not seen  left
enemy at (4, -2)    dot  0.8944  in view   right
off the floor:    (3, 4)
off the 45° wall: (4, -3)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dot_cross_session.py

The enemy at (5, 1) is 11° off the guard’s facing, so its dot product, 0.9806, passes the test, while (2, 4), 63° away, fails it with 0.4472. The enemy at (4, −2) is in view on the right, and the one at (−3, 0.5) is almost straight behind, with a dot product of −0.9864. The ball’s velocity (3, −4) leaves the floor as (3, 4) and the 45° wall as (4, −3).

The model below starts with the guard’s facing, a = (1, 0, 0), and the enemy at (5, 1) as b. Its angle, 11.3°, is the one the dot product tests, and the positive z of a × b says left. Set b to (4, −2, 0) and the z turns negative.

Interactive · in 3D The guard's facing a and the way to an enemy b Set the coordinates of a and b. The dot product measures how much they point the same way; the cross product is perpendicular to both, as long as the area of the parallelogram they span.
Vectors a = (1, 0, 0) and b = (5, 1, 0) on x, y and z axes, with a × b.xyzaba × b (half length)

a = (1, 0, 0), b = (5, 1, 0): a · b = 5, a × b = (0, 0, 1).

a · b 5 · aₓbₓ + a_yb_y + a_zb_z
Angle between a and b 11.3° · cos θ = a · b ÷ (|a| |b|)
a × b (0, 0, 1) · (a_yb_z − a_zb_y, a_zb_x − a_xb_z, a_xb_y − a_yb_x)
|a × b| (parallelogram area) 1
Projection of a on b 0.981 · a · b ÷ |b|

Vector Calculator Check dot products, cross products and angles of your own vectors, with the steps shown.

Checkpoint

Check: The dot and cross products

  1. Question 1 of 2 A guard faces (0, 1) and sees 30° to either side. The normalised direction to an enemy is (0.6, 0.8). Is the enemy in view?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No, because the dot product is 0.8, below cos 30° = 0.866

    For unit vectors the dot product is the cosine of the angle between them. 0.8 is below 0.866, so the angle, about 37°, is wider than 30°: the enemy is outside the cone.

  2. Question 2 of 2 The y axis points up. A guard at the origin faces (1, 0), and an enemy stands at (2, −3). On which side is it, by vec.h's cross()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: On the right, because cross((1, 0), (2, −3)) = 1 × (−3) − 0 × 2 = −3 is negative

    cross(a, b) = a.x × b.y − a.y × b.x = 1 × (−3) − 0 × 2 = −3. Negative means clockwise, to the right when y points up, as the enemy at (4, −2) in dot_cross.cpp was. With y pointing down, the sign flips.

Matrices move, turn and stretch points, and their order matters

A 3×3 matrix transforms 2D points written as (x, y, 1). The extra 1 lets one matrix also translate, so a single matrix can hold a whole object’s placement. mat3.h builds three kinds:

  • translation(t) moves every point by the vector t;
  • rotation(degrees) turns points anticlockwise about the origin;
  • scale(s) stretches along the x and y axes; with two different factors it is non-uniform.
mat3.h C header · mat3.h
// A 3x3 matrix for 2D transforms. A point (x, y) is used as the column (x, y, 1), so one matrix
// can rotate, scale and translate; m[row][column], and the default is the identity.
#pragma once
#include <cmath>
#include <numbers>
#include "vec.h"

struct Mat3 {
    float m[3][3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}};
};

// a * b applies b first, then a.
constexpr Mat3 operator*(const Mat3& a, const Mat3& b) {
    Mat3 r;
    for (int i = 0; i < 3; ++i) {
        for (int j = 0; j < 3; ++j) {
            float sum = 0.0f;
            for (int k = 0; k < 3; ++k) sum += a.m[i][k] * b.m[k][j];
            r.m[i][j] = sum;
        }
    }
    return r;
}

constexpr Mat3 translation(Vec2 t) { return {{{1, 0, t.x}, {0, 1, t.y}, {0, 0, 1}}}; }
constexpr Mat3 scale(Vec2 s) { return {{{s.x, 0, 0}, {0, s.y, 0}, {0, 0, 1}}}; }
inline Mat3 rotation(float degrees) {  // anticlockwise, about the origin
    const float a = degrees * std::numbers::pi_v<float> / 180.0f;
    const float c = std::cos(a);
    const float s = std::sin(a);
    return {{{c, -s, 0}, {s, c, 0}, {0, 0, 1}}};
}

// A point moves with the translation; a direction, such as a velocity, does not.
constexpr Vec2 transform_point(const Mat3& t, Vec2 p) {
    return {t.m[0][0] * p.x + t.m[0][1] * p.y + t.m[0][2], t.m[1][0] * p.x + t.m[1][1] * p.y + t.m[1][2]};
}
constexpr Vec2 transform_vector(const Mat3& t, Vec2 v) {
    return {t.m[0][0] * v.x + t.m[0][1] * v.y, t.m[1][0] * v.x + t.m[1][1] * v.y};
}

The product a * b applies b first, then a, so a chain of matrices reads from right to left. Swapping two matrices usually changes the result:

order.cpp C++ · order.cpp
// The same two transforms of the point (1, 0), in both orders: turn 30° about the origin, and
// move 5 m to the right.
#include <print>
#include "mat3.h"

int main() {
    const Vec2 p{1.0f, 0.0f};
    const Mat3 turn = rotation(30.0f);
    const Mat3 move = translation({5.0f, 0.0f});

    const Mat3 turn_then_move = move * turn;  // the right-hand matrix acts first
    const Mat3 move_then_turn = turn * move;
    std::println("turn, then move: {}", str(transform_point(turn_then_move, p)));
    std::println("move, then turn: {}", str(transform_point(move_then_turn, p)));

    // A velocity is a direction: the translation does not change it.
    std::println("velocity (2, 0) through turn_then_move: {}", str(transform_vector(turn_then_move, {2.0f, 0.0f})));
}
Turn and move, in both orders Python · order_session.py
from session import run

# order.cpp (with mat3.h) built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off order.cpp -o order && ./order
g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off order.cpp -o order-gcc && ./order-gcc
""")

Output

$ clang++ -std=c++23 -O2 -Wall -Wextra -ffp-contract=off order.cpp -o order && ./order
turn, then move: (5.8660254, 0.5)
move, then turn: (5.196152, 3)
velocity (2, 0) through turn_then_move: (1.7320508, 1)
$ g++-16 -std=c++23 -O2 -Wall -Wextra -ffp-contract=off order.cpp -o order-gcc && ./order-gcc
turn, then move: (5.8660254, 0.5)
move, then turn: (5.196152, 3)
velocity (2, 0) through turn_then_move: (1.7320508, 1)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_session.py

Turning (1, 0) by 30° and then moving it gives (5.866, 0.5); moving it first and then turning the moved point about the origin gives (5.196, 3). Step through both orders:

Interactive · step by step The same two matrices, applied in both orders Step through order.cpp. The left column is move * turn, which turns first; the right column is turn * move, which moves first.
The same two matrices, applied in both orders, step 5 of 5. Showing move * turn, turned 30°, then moved 5 m, turn * move, moved 5 m, then turned 30°. In focus: then turned 30°, then moved 5 m.rotation(30)translation({5, 0})translation({5, 0})rotation(30)move * turnstarts at (1, 0)turned 30°(0.866, 0.5)then moved 5 m(5.866, 0.5)turn * movestarts at (1, 0)moved 5 m(6, 0)then turned 30°(5.196, 3)rotation(30)

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. One point, two products. Both columns start from the point (1, 0) and use the same two matrices of order.cpp, rotation(30) and translation({5, 0}). Only the order of the product differs.
  2. move * turn: the turn acts first. In a product a * b, b acts first. So move * turn first turns the point 30° anticlockwise about the origin, to (cos 30°, sin 30°) = (0.866, 0.5).
  3. then the move. The translation then adds (5, 0): (5.866, 0.5), the point order.cpp printed for turn, then move. The object turned on the spot and moved right.
  4. turn * move: the move acts first. turn * move first moves the point 5 m to the right, to (6, 0).
  5. then a turn about the origin. The turn is still about the origin, so the moved point swings along a circle of radius 6, to (5.196, 3): 2.6 m from the other result. Same matrices, different product.

A velocity goes through transform_vector, which ignores the translation, because a direction has no position: (2, 0) turned by 30° is (1.732, 1) wherever the object stands.

Three more cases need care. Rotating about a point other than the origin takes three matrices in one order. Surface normals need a different matrix from points once a scale is non-uniform. Going back from the world to an object’s own space needs the inverse of its matrix, which is cheap when the matrix only rotates and translates.

Matrix Calculator Multiply two 3×3 matrices both ways to see that the products differ.

Checkpoint

Check: Matrices and the order of a product

  1. Question 1 of 2 In mat3.h, which matrix of the product a * b acts first on a point?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: b, so a chain of matrices reads from right to left

    With column vectors the point is multiplied from the right, M · p, so in a * b the matrix b touches the point first, and a chain such as move * turn reads from right to left: turn, then move.

  2. Question 2 of 2 M = translation({2, 0}) * rotation(90.0f). Where does transform_point(M, {1, 0}) put the point?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: (2, 1)

    Read the product from right to left: rotation(90) takes (1, 0) to (0, 1), then translation({2, 0}) gives (2, 1). The other order, rotation * translation, would give (0, 3).

Practise: a Vec3 for 3D

vec3.h holds a Vec3 with its operators; dot, cross, normalized and triangle_normal are yours to write. The tests include the degenerate cases: a zero vector and a triangle whose corners lie on one line.

The starter and its sample tests

C header · vec3.h

// The exercise: a 3D vector with the operations a 3D game needs. Fill in the four TODOs.
#pragma once
#include <cmath>

struct Vec3 {
    float x = 0.0f;
    float y = 0.0f;
    float z = 0.0f;
};

constexpr Vec3 operator+(Vec3 a, Vec3 b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
constexpr Vec3 operator-(Vec3 a, Vec3 b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
constexpr Vec3 operator-(Vec3 v) { return {-v.x, -v.y, -v.z}; }
constexpr Vec3 operator*(Vec3 v, float s) { return {v.x * s, v.y * s, v.z * s}; }
constexpr Vec3 operator*(float s, Vec3 v) { return v * s; }
constexpr Vec3 operator/(Vec3 v, float s) { return {v.x / s, v.y / s, v.z / s}; }

// TODO 1: the dot product of a and b.
constexpr float dot(Vec3 a, Vec3 b) {
    (void)a;
    (void)b;
    return 0.0f;
}

// TODO 2: the cross product: perpendicular to both a and b, and as long as the area of the
// parallelogram they span. cross({1, 0, 0}, {0, 1, 0}) is {0, 0, 1}.
constexpr Vec3 cross(Vec3 a, Vec3 b) {
    (void)a;
    (void)b;
    return {};
}

inline float length(Vec3 v) { return std::sqrt(dot(v, v)); }

// TODO 3: the unit vector pointing like v. A zero vector stays zero.
inline Vec3 normalized(Vec3 v) { return v; }

// TODO 4: the unit normal of the triangle a, b, c: it points to the side from which the corners
// run anticlockwise. Three corners on one line make no triangle: return the zero vector.
inline Vec3 triangle_normal(Vec3 a, Vec3 b, Vec3 c) {
    (void)a;
    (void)b;
    (void)c;
    return {};
}

C++ · vec3_tests.cpp

// Sample tests for vec3.h. Build: clang++ -std=c++23 -Wall -Wextra vec3_tests.cpp -o vec3_tests
#include <cmath>
#include <print>
#include "vec3.h"

int passed = 0;
int failed = 0;

void expect(bool ok, const char* what) {
    if (ok) {
        ++passed;
    } else {
        ++failed;
        std::println("FAIL {}", what);
    }
}

// Floats round, so results are compared with a tolerance, never with ==.
bool near(float a, float b) { return std::abs(a - b) < 1e-6f; }
bool near(Vec3 a, Vec3 b) { return near(a.x, b.x) && near(a.y, b.y) && near(a.z, b.z); }

int main() {
    const Vec3 x{1, 0, 0}, y{0, 1, 0}, z{0, 0, 1};
    const Vec3 a{1, 2, 3}, b{-2, 0.5f, 4};

    expect(near(dot({1, 2, 3}, {4, -5, 6}), 12.0f), "dot((1, 2, 3), (4, -5, 6)) is 12");
    expect(near(cross(x, y), z) && near(cross(y, x), -z), "x cross y is z, and y cross x is -z");
    expect(near(dot(cross(a, b), a), 0.0f) && near(dot(cross(a, b), b), 0.0f) && length(cross(a, b)) > 1.0f,
           "a cross b is not zero, and perpendicular to a and to b");
    expect(near(length(cross({3, 0, 0}, {0, 4, 0})), 12.0f) && near(cross({2, 4, 6}, {1, 2, 3}), {}),
           "the cross product's length is the area: 12 for 3 by 4, 0 for parallel vectors");
    expect(near(normalized({3, 4, 12}), {3.0f / 13, 4.0f / 13, 12.0f / 13}) && near(length(normalized(b)), 1.0f),
           "normalized keeps the direction and makes the length 1");
    const Vec3 zero = normalized({});
    expect(near(normalized({0, 0, 5}), z) && !std::isnan(zero.x) && near(zero, {}),
           "normalizing (0, 0, 5) gives (0, 0, 1), and the zero vector stays zero, not NaN");
    expect(near(triangle_normal({0, 0, 0}, {1, 0, 0}, {0, 1, 0}), z) && near(triangle_normal({0, 0, 0}, {0, 1, 0}, {1, 0, 0}), -z),
           "an anticlockwise triangle in the xy plane faces +z, a clockwise one -z");
    expect(near(length(triangle_normal({0, 0, 0}, {2, 0, 0}, {0, 0, 2})), 1.0f) && near(triangle_normal({0, 0, 0}, {1, 1, 1}, {2, 2, 2}), {}),
           "a normal has length 1, and three corners on one line give the zero vector");

    std::println("{} of {} checks passed", passed, passed + failed);
    return failed == 0 ? 0 : 1;
}
The sample tests against the starter Python · vec3_session.py
from session import run

# The sample tests against the starter code, before the TODOs are done.
run("""
clang++ -std=c++23 -Wall -Wextra vec3_tests.cpp -o vec3_tests && ./vec3_tests
g++-16 -std=c++23 -Wall -Wextra vec3_tests.cpp -o vec3_tests-gcc && ./vec3_tests-gcc
""")

Output

$ clang++ -std=c++23 -Wall -Wextra vec3_tests.cpp -o vec3_tests && ./vec3_tests
FAIL dot((1, 2, 3), (4, -5, 6)) is 12
FAIL x cross y is z, and y cross x is -z
FAIL a cross b is not zero, and perpendicular to a and to b
FAIL the cross product's length is the area: 12 for 3 by 4, 0 for parallel vectors
FAIL normalized keeps the direction and makes the length 1
FAIL normalizing (0, 0, 5) gives (0, 0, 1), and the zero vector stays zero, not NaN
FAIL an anticlockwise triangle in the xy plane faces +z, a clockwise one -z
FAIL a normal has length 1, and three corners on one line give the zero vector
0 of 8 checks passed
[exit status 1]
$ g++-16 -std=c++23 -Wall -Wextra vec3_tests.cpp -o vec3_tests-gcc && ./vec3_tests-gcc
FAIL dot((1, 2, 3), (4, -5, 6)) is 12
FAIL x cross y is z, and y cross x is -z
FAIL a cross b is not zero, and perpendicular to a and to b
FAIL the cross product's length is the area: 12 for 3 by 4, 0 for parallel vectors
FAIL normalized keeps the direction and makes the length 1
FAIL normalizing (0, 0, 5) gives (0, 0, 1), and the zero vector stays zero, not NaN
FAIL an anticlockwise triangle in the xy plane faces +z, a clockwise one -z
FAIL a normal has length 1, and three corners on one line give the zero vector
0 of 8 checks passed
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vec3_session.py

Edit vec3.h until both compilers print 8 of 8 checks passed. The cross product is (a.y b.z − a.z b.y, a.z b.x − a.x b.z, a.x b.y − a.y b.x); a triangle’s normal is the normalised cross product of two of its edges, b - a and c - a, and normalized turns a degenerate triangle’s zero cross product into the zero vector.

Practice starts with the idea you know least.

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 Why does vec.h store Vec2's coordinates as float rather than double?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: A float Vec2 takes 8 bytes and a double one 16, half the memory for the many positions a game keeps

    Two floats make an 8-byte value, two doubles a 16-byte one. A game that keeps millions of positions halves their memory with float, and pays for it with fewer significant digits.

  2. Question 2 of 10 values.cpp has static_assert(after_half_a_second.x == 1.5f && after_half_a_second.y == 4.0f). What does the build do if operator* were written wrongly?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: It fails to compile, because the compiler evaluates the constexpr arithmetic and the assertion is false

    start + velocity * 0.5f calls constexpr operators on constexpr values, so the compiler computes (1.5, 4) itself, and the static_assert turns a wrong result into a build error.

  3. Question 3 of 10 In a review, a teammate changes every Vec2 parameter to const Vec2& "for speed". What is the best reply?

    Choose one answer.

    Show the answer: Question 3 of 10

    Answer: For an 8-byte trivially copyable value, by value is at least as cheap and cannot alias, so keep it by value

    values.cpp showed Vec2 is 8 bytes and trivially copyable. Passing it by value costs the same as passing an address, avoids reading through a pointer, and makes it impossible for two parameters to refer to the same vector.

  4. Question 4 of 10 facing is a unit vector, and to_enemy has been normalised. dot(facing, to_enemy) is 0.5. Where is the enemy?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: 60° away from the facing direction, outside a view cone of 30° to either side

    For unit vectors the dot product is the cosine of the angle between them, and cos 60° = 0.5. A cone of 30° to either side needs at least cos 30° = 0.866; a positive value only says the enemy is somewhere in front.

  5. Question 5 of 10 With the y axis pointing up, cross(facing, to_target) (the 2D cross product of vec.h) is −0.3. On which side of the guard is the target?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: On the right, because a negative value means clockwise from the facing direction

    The 2D cross product is positive when the second vector is anticlockwise (to the left) of the first, and negative when it is clockwise (to the right). With y pointing down, as in most screen coordinates, the signs swap.

  6. Question 6 of 10 A ball with velocity (2, −6) hits the floor, whose unit normal is (0, 1). Using r = v − 2 (v · n) n, what is the y part of the velocity it leaves with?

    Type a number.

    Show the answer: Question 6 of 10

    Answer: 6 m/s (anything from 5.999 to 6.001 counts)

    v · n = −6, so r = (2, −6) − 2 × (−6) × (0, 1) = (2, 6). The part along the normal flips; the part along the floor, 2, stays.

  7. Question 7 of 10 You port the guard code to screen coordinates, where y points down. An enemy that the old code called "left" now gets a negative cross product. What do you change?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: Read a negative cross product as left in screen coordinates, because flipping y flips the sign

    The sign rule "positive means left" holds when y points up. With y pointing down, as in most screen coordinates, the same geometry gives the opposite sign, so the test must be read the other way round.

  8. Question 8 of 10 M = translation({5, 0}) * rotation(90.0f) with mat3.h. What does transform_point(M, p) do to a point p?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: It turns p by 90° about the origin, then moves the result 5 to the right

    a * b applies b first. order.cpp showed that the two orders give different points: (5.866, 0.5) against (5.196, 3) for a 30° turn of (1, 0).

  9. Question 9 of 10 An object placed by move * turn, with move = translation({5, 0}) and turn = rotation(30), flies with velocity (2, 0) in its own space. Through transform_vector, what is its velocity in the world?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: (1.732, 1), turned by 30° but not moved

    Points go through transform_point, which adds the translation column; directions such as velocities go through transform_vector, which uses only the rotation and scale.

  10. Question 10 of 10 A sprite must be scaled by 2, then turned 90°, then placed at (10, 0). Which single matrix does that, with mat3.h?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: translation({10, 0}) * rotation(90) * scale({2, 2})

    The first transform to act is the last factor written, so the brief's order, scale, turn, move, is written from right to left: translation * rotation * scale.

Transforms in depth: pivots, normals and inverses, with numbers Pro: opens with a Pro, Premium or Ultimate pass

Rotating about a pivot, transforming normals and getting back from world space to an object’s own space are where matrix code most often goes wrong. Each case below runs next to a version that looks right and is not.

In this part:

  • Rotating about a pivot: three matrices in one order
  • Normals under a non-uniform scale, reproduced
  • From world space to an object’s space: the cheap inverse
  • Column vectors or row vectors: reading another library’s maths

1 diagram · 6 runnable examples (C++, Python) · about 7 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Floating-point pitfalls in game maths, reproduced and measured Pro: opens with a Pro, Premium or Ultimate pass

Floats round at every operation, and game code meets the consequences in four places: zero vectors, equality tests, values updated millions of times, and coordinates far from the origin. Each one is reproduced here with both compilers, together with the fix that holds.

In this part:

  • A zero vector, a NaN that spreads, and a check that fast-math builds remove
  • Comparing with a tolerance, and choosing one
  • Drift: a rotation applied a million times
  • Far from the origin: float spacing, measured

8 runnable examples (C++, Python) · about 6 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

  1. Fresher · Warm-up

    What do the dot product and the 2D cross product of two vectors tell a game, and which one says whether an enemy is to the player's left?

    The dot product, a.x b.x + a.y b.y, equals |a| |b| cos θ: for unit vectors it is the cosine of the angle between them, positive in front, zero at a right angle and negative behind. That makes it the test for a view cone (compare it with the cosine of the half angle) and the tool for projecting and reflecting velocities. The 2D cross product, a.x b.y − a.y b.x, equals |a| |b| sin θ, so its sign says on which side b lies: it answers the left-or-right question. Positive means left, anticlockwise, when y points up, and the sign flips in screen coordinates, where y points down. Its size is the area of the parallelogram the vectors span, zero for parallel vectors. In 3D the cross product is a vector perpendicular to both, which gives surface normals.

  2. Fresher to mid

    A sprite should spin about its own centre. In your code it swings in a wide circle around the corner of the screen, and when you reverse the order of the matrices it flies off the screen. Explain both bugs and write the transform that is right.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    After an artist stretches a level to twice its width, balls bounce off sloped walls at wrong angles and some pass through them. Flat floors and upright walls still behave. What is wrong in the maths, and how do you fix it in the engine?

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    In an open-world game, vehicles 80 km from the world's origin shake on screen, and a door that opens 1 mm per frame never moves. Positions are floats in metres. Explain both symptoms with numbers, and compare three fixes.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Vectors are small aggregates with constexpr operators taking values; normalized guards the zero vector.
  • The dot product of unit vectors is a cosine, compared with the half angle’s cosine to test a view cone.
  • The sign of the 2D cross product tells left from right, and flips when y points down.
  • A 3×3 matrix holds a 2D rotation, scale and translation, and in a * b the matrix b acts first.
  • Computed positions are compared with a tolerance, never with ==.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.