Article
+ UpdatedGame Math Basics - What Dot and Cross Products Do in Games
Is the target in front or behind? On the left or the right? Inside the field of view? None of these questions need trigonometry. A single dot or cross product answers each one.
The problem: did the guard see you?
Say you're making a stealth game. A guard stands in a corridor, looking in some direction. Every frame you need to answer:
Is the player in front of the guard or behind them? (backstab check)
Is the player to the guard's left or right? (which way the guard should turn their head)
Is the player inside the guard's 90° vision cone?
A beginner's first instinct is often to compute two angles with atan2, subtract them, and then deal with the wraparound between 359° and 1°. These questions actually have simpler, faster answers: the dot product and the cross product.
Coordinate convention
All code in this series uses screen coordinates: x points right, y points down. This affects what the cross product tells you about left and right, which gets its own explanation below.
A 2D vector needs just two floats, plus the usual operators:
struct Vec2 {
float x = 0.0f;
float y = 0.0f;
constexpr Vec2() = default;
constexpr Vec2(float x_, float y_) : x(x_), y(y_) {}
constexpr Vec2 operator+(Vec2 o) const { return {x + o.x, y + o.y}; }
constexpr Vec2 operator-(Vec2 o) const { return {x - o.x, y - o.y}; }
constexpr Vec2 operator*(float s) const { return {x * s, y * s}; }
constexpr Vec2 operator/(float s) const { return {x / s, y / s}; }
constexpr Vec2 operator-() const { return {-x, -y}; }
constexpr Vec2& operator+=(Vec2 o) { x += o.x; y += o.y; return *this; }
constexpr Vec2& operator-=(Vec2 o) { x -= o.x; y -= o.y; return *this; }
constexpr Vec2& operator*=(float s) { x *= s; y *= s; return *this; }
friend constexpr Vec2 operator*(float s, Vec2 v) { return v * s; }
friend constexpr bool operator==(Vec2 a, Vec2 b) = default;
};Dot product: front/back and projection
// Dot product: |a||b|cosθ. Positive = roughly the same direction, 0 = perpendicular, negative = roughly opposite
constexpr float dot(Vec2 a, Vec2 b) { return a.x * b.x + a.y * b.y; }
// 2D cross product (also called perp-dot): |a||b|sinθ. Its sign tells you which side of a b is on
constexpr float cross(Vec2 a, Vec2 b) { return a.x * b.y - a.y * b.x; }Geometrically, dot(a, b) = |a|·|b|·cosθ, where θ is the angle between the two vectors. When we don't care about the exact value, the sign alone is enough:
DOT(A, B) | Angle θ | Meaning |
|---|---|---|
Greater than 0 | Less than 90° | Roughly the same direction: target is in front |
Equal to 0 | Exactly 90° | Perpendicular: target is directly to the side |
Less than 0 | Greater than 90° | Roughly opposite: target is behind |
// Dot > 0: the angle between facing and target is under 90°, so the target is "in front"
inline bool isInFront(Vec2 facing, Vec2 toTarget) { return dot(facing, toTarget) > 0.0f; }If a is a unit vector, dot(a, b) also happens to equal the length of b's projection onto a, i.e. "how far away the target is along the direction I'm facing". In the demo, the length of the ochre line segment is exactly this dot product.
Cross product: left or right
The 2D cross product cross(a, b) = |a|·|b|·sinθ is a signed number, and its sign tells you which side of a b is on:
enum class Side { Left, Right, Straight };
// The sign of the cross product decides left vs. right. Note: screen y points down, so the result is the opposite of a math textbook (y up)
inline Side sideOf(Vec2 facing, Vec2 toTarget, float epsilon = 1e-4f) {
float c = cross(facing, toTarget);
if (c > epsilon) return Side::Right;
if (c < -epsilon) return Side::Left;
return Side::Straight;
}With y pointing down, left and right are flipped
In a math textbook the y axis points up, and
cross > 0means b is to the left of a (counterclockwise). In screen coordinates y points down, the whole picture is flipped vertically, andcross > 0becomes right. When you switch engines or coordinate systems, this is the easiest thing to get wrong.
Try it yourself
Drag the red dot (player), the black dot (guard), or the arrow in front of the guard. While playing, the guard sweeps left and right. The bottom-left corner shows the dot product, cross product and local coordinates live.
A few experiments worth trying:
Drag the player directly beside the guard and
dotapproaches 0; drag them behind anddotgoes negative, with the ochre segment pointing backward.Move the player back and forth across the guard's line of sight and watch the sign of
crossflip, along with the switch betweenleftandright.Lower
fov, then put the player near the edge of the vision cone and see whencan seechanges.Compare the two components of
localwithdotandcross. You'll find they're the same pair of numbers (explained below).
Vision cones without acos
To check "is the angle smaller than half the field of view", the intuitive version is acos(dot(f, normalize(to))) < halfAngle. But acos is slow, and you have to normalize first. Take the cosine of both sides and multiply through by |to|, and it becomes a single multiply-and-compare:
struct VisionCone {
Vec2 position;
Vec2 facing; // unit vector
float halfAngle; // half of the field of view (radians)
float range;
};
// No acos, and no need to normalize toTarget first:
// dot(f, to) >= |to| · cos(halfAngle) ⇔ angle <= halfAngle
inline bool canSee(const VisionCone& cone, Vec2 target) {
Vec2 to = target - cone.position;
float d2 = lengthSq(to);
if (d2 > cone.range * cone.range) return false; // do the cheap distance check first
if (d2 < 1e-8f) return true;
return dot(cone.facing, to) >= std::cos(cone.halfAngle) * std::sqrt(d2);
}In a real project, cos(halfAngle) can be precomputed whenever the vision parameters change, and distance comparisons use squared length to skip the square root:
constexpr float lengthSq(Vec2 v) { return dot(v, v); } // use this when comparing distances to skip the sqrt
inline float length(Vec2 v) { return std::sqrt(dot(v, v)); }
inline float distance(Vec2 a, Vec2 b) { return length(b - a); }
inline Vec2 normalized(Vec2 v) {
float len = length(v);
return len > 1e-6f ? v / len : Vec2{}; // a zero vector has no direction; return zero instead of NaN
}Matrices: seeing the world from another point of view
You can rotate a vector directly with trig functions:
// Perpendicular vector: in y-down screen coordinates this is "rotate 90° clockwise", i.e. the right-hand direction when facing v
constexpr Vec2 perp(Vec2 v) { return {-v.y, v.x}; }
inline Vec2 fromAngle(float radians) { return {std::cos(radians), std::sin(radians)}; }
inline float angleOf(Vec2 v) { return std::atan2(v.y, v.x); }
inline Vec2 rotate(Vec2 v, float radians) {
float c = std::cos(radians), s = std::sin(radians);
return {v.x * c - v.y * s, v.x * s + v.y * c};
}
// Signed angle from a to b, in the range (-π, π]
inline float signedAngle(Vec2 a, Vec2 b) { return std::atan2(cross(a, b), dot(a, b)); }But when we need to convert back and forth between "world coordinates" and "an object's local coordinates", a matrix is the better tool. Understanding a 2×2 rotation matrix takes one sentence: the two columns of the matrix are the x axis and y axis after rotation.
// Stored as two columns: col0 is the transformed x axis, col1 is the transformed y axis
struct Mat2 {
Vec2 col0{1, 0};
Vec2 col1{0, 1};
static Mat2 rotation(float radians) {
float c = std::cos(radians), s = std::sin(radians);
return {{c, s}, {-s, c}};
}
// Matrix × vector = weighted sum of the columns by component
Vec2 operator*(Vec2 v) const { return col0 * v.x + col1 * v.y; }
Mat2 operator*(const Mat2& o) const { return {(*this) * o.col0, (*this) * o.col1}; }
Mat2 transposed() const { return {{col0.x, col1.x}, {col0.y, col1.y}}; }
};An object's transform = rotation + translation. Going from local to world coordinates means rotate first, then translate; going the other way means translate back first, then rotate in reverse. The columns of a rotation matrix are perpendicular to each other and have length 1, so its inverse is just its transpose:
// Position + orientation: the most common transform for a game object (scale omitted here)
struct Transform2D {
Vec2 position;
float rotation = 0.0f;
Vec2 forward() const { return fromAngle(rotation); }
Vec2 right() const { return perp(forward()); }
// Local -> world: rotate first, then translate
Vec2 toWorld(Vec2 local) const { return position + Mat2::rotation(rotation) * local; }
// World -> local: translate back first, then rotate in reverse. The inverse of a rotation matrix is its transpose
Vec2 toLocal(Vec2 world) const { return Mat2::rotation(rotation).transposed() * (world - position); }
};Expand the transposed matrix times a vector and it turns out to be one dot product along the "forward" axis and one along the "right" axis. The x component of the local coordinates is dot(to, forward), and the y component is dot(to, right). Dot products and matrices are really the same thing:
// Equivalent version: local coordinates are just one dot product each along the "forward" and "right" axes
inline Vec2 toLocalByDot(const Transform2D& t, Vec2 world) {
Vec2 d = world - t.position;
return {dot(d, t.forward()), dot(d, t.right())};
}Projection and reflection
Two more operations that come up constantly: projecting a vector onto a direction (for example, splitting a velocity into the part along a wall and the part into it), and reflecting across a normal (bullets bouncing off walls, billiard balls hitting the cushion):
// Projection of v onto the direction of onto (onto doesn't need to be a unit vector)
inline Vec2 project(Vec2 v, Vec2 onto) {
float d = lengthSq(onto);
return d > 1e-12f ? onto * (dot(v, onto) / d) : Vec2{};
}
// Reflect v across the unit normal n: bullets bouncing off walls and light reflecting both use this
constexpr Vec2 reflect(Vec2 v, Vec2 n) { return v - n * (2.0f * dot(v, n)); }Common pitfalls
Problem | Cause | Fix |
|---|---|---|
NaN appears and objects teleport or vanish | Normalizing a zero vector (dividing by 0) |
|
Vision check works only some of the time | The facing vector isn't normalized, so its length inflates the dot product | Guarantee facing is a unit vector when you store it |
Left and right come out exactly reversed | The coordinate system's y direction doesn't match the formula's assumption | Write a unit test that checks one known example |
Angle interpolation or comparison goes wrong | Mixing degrees and radians, or not handling the ±180° wraparound | Use radians everywhere internally; prefer vectors over angles when you can |
Distance comparisons slow things down | Calling | Use |
In real games
Backstab checks: if the dot product of the attacker's facing and the victim's facing is above some threshold (roughly the same direction), the attack came from behind.
Steering decisions: when an AI needs to turn toward a target, the sign of the cross product decides whether to turn left or right, with no angle math.
Lighting: diffuse brightness is proportional to
dot(normal, light direction). It's the first line of nearly every shader.Which side of a line a point is on: the sign of
cross(b - a, p - a). Point-in-triangle tests, polygon convexity checks and collision detection all use it (see Volume 2).In 3D: the dot product means exactly the same thing; the cross product instead gives a new vector perpendicular to both inputs, which is the basis for computing normals and building coordinate frames.
Further reading
3Blue1Brown, Essence of Linear Algebra: builds intuition for matrices and dot products with animation
Fletcher Dunn and Ian Parberry, 3D Math Primer for Graphics and Game Development
Newsletter
Subscribe
Get an email when a new article is published. Unsubscribe anytime.
Copyright
Copyright of this article belongs to its author or rights holder. When reposting or quoting, please follow the applicable license terms and keep the link to this article.
XuYi, “Game Math Basics - What Dot and Cross Products Do in Games”, https://xuyi.dev/en/2026-11-06-bgcegv
Comments
Comments
Loading comments…