Facing Is Not Position: Vectors, Dot and Cross Products and Coordinate Spaces in Games

Before a ship can decide whether to fire or which way to turn, it needs three answers: is the target in front of it, is it to the left or the right, and is it within range? Each of those is a single vector operation. The dot product gives you the angle, the 2D cross product gives you the side and the length gives you the distance. Add coordinate transforms and the gun turns with the hull; split the velocity into two parts on impact and the ship slides along a wall instead of sticking to it.

None of the formulas is hard. What goes wrong in practice is losing track of what each quantity means. This is the first post in a short series on describing a game world with maths, all built around one made-up space-mining game, and it starts with the ship’s sense of space.

Four things that look the same

In a 2D game all of these are a pair (x, y):

  • Position: where a point is in the world, say the ship at (10, 5).
  • Displacement: the difference between two points, target position - ship position, which has both direction and distance.
  • Direction: a vector of length 1. It says which way, never how far.
  • Velocity: displacement per second, whose length is the speed.

Nothing in the type system stops you mixing them up. Adding two positions means nothing; subtracting them gives a displacement; dividing a displacement by its own length gives a direction. Get it wrong and nothing throws an error; the ship just heads somewhere odd. The difference matters again in coordinate transforms, where positions and directions take different formulas.

Dot product: is the target in view?

The dot product a·b = ax*bx + ay*by equals the product of the two lengths times the cosine of the angle between them. If both are unit directions, it is simply that cosine: 1 straight ahead, 0 directly to the side, -1 directly behind.

So a field-of-view check never needs an angle. Take the dot product of the ship’s forward and the direction to the target, and compare it with the cosine of half the cone. Godot’s vector maths tutorial uses a zombie spotting the player and checks for a dot product above 0, meaning an angle of less than 90°. Work out the cosine once and the inner loop is a few multiplications and additions.

The other use is projection. The dot product of a vector with a direction is its component along that direction. Closing speed works this way: project the relative velocity of the two ships onto the line between them. Positive means the gap is shrinking, negative means it is opening.

2D cross product: turn left or right?

In 3D the cross product is a vector perpendicular to both inputs. In 2D you keep only its z component, a×b = ax*by - ay*bx, and its sign tells you which side of a the vector b is on. With the y axis pointing up, positive means b is to the left (anticlockwise) and negative means to the right.

That sign is the steering decision. Exactly zero means the target is dead ahead or dead behind and needs its own case, otherwise floating-point noise will have the ship twitching between left and right. Screen coordinates often point y downwards, which swaps left and right, so a project should settle on one convention early.

Local and world space

The gun sits forward and to the right of the bow, at (4, 1) in the ship’s own coordinates, with its barrel pointing along (1, 0). Once the ship has turned 90° and moved to (10, 5), you need the muzzle’s position in the world and the direction a shot will travel.

A point is rotated and then has the ship’s position added. A direction is only rotated, because a direction has no location. Unity keeps these apart as two calls: TransformPoint for positions and TransformDirection for directions, and its documentation says the latter ignores the transform’s position and scale and returns a vector of the same length. Treat a direction as a point and the heading comes out wrong.

Sliding along a wall

The ship hits a 45° wall at (20, 0). The wall’s normal n has length 1 and points out of the wall. Zeroing the velocity on contact makes the ship feel glued in place. Removing only the part that goes into the wall works better:

v_into  = (v·n) n
v_slide = v - v_into

A negative v·n means the ship is moving into the wall, and what remains after subtracting that part runs along the surface. Subtract it twice, v - 2(v·n)n, and you get a bounce, which is what Godot’s bounce() does.

A zero vector has no direction

Normalising divides by the length, and a length of zero leaves nothing to divide by. The target sitting on top of the ship, an untouched joystick or two overlapping objects all give you a zero vector. Godot’s documentation notes that GDScript’s normalized() returns a zero vector unchanged rather than failing. Other engines, or your own helper, may return NaN instead, and every value computed from it afterwards turns into NaN as well. It is safer to let the caller decide what “no direction” means, for example keeping the previous heading.

Putting it together

Here is all of the above in one small program, run on Python 3.13.16. The ship sits at the origin facing +x, with a 60° cone and a range of 100:

import math
import sys
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class V2:
    x: float
    y: float

    def __add__(self, o): return V2(self.x + o.x, self.y + o.y)
    def __sub__(self, o): return V2(self.x - o.x, self.y - o.y)
    def __mul__(self, k): return V2(self.x * k, self.y * k)
    def dot(self, o): return self.x * o.x + self.y * o.y
    def cross(self, o): return self.x * o.y - self.y * o.x   # 2D: z component only
    def length(self): return math.hypot(self.x, self.y)

    def normalized(self, eps=1e-9):
        n = self.length()
        if n < eps:
            return None        # caller decides what "no direction" means
        return V2(self.x / n, self.y / n)

    def __repr__(self): return f"({self.x:.2f}, {self.y:.2f})"


def heading(deg):
    r = math.radians(deg)
    return V2(math.cos(r), math.sin(r))


# The ship sits at the origin, nose pointing along +x (heading 0 deg).
ship_pos, ship_fwd = V2(0, 0), heading(0)
FOV_HALF = 30                      # 60 deg cone
RANGE = 100
cos_half = math.cos(math.radians(FOV_HALF))

print("== 1. in front, to the side, or out of range ==")
for name, target in [("A", V2(80, 20)), ("B", V2(40, 60)),
                     ("C", V2(-50, 5)), ("D", V2(150, 0))]:
    to_t = target - ship_pos
    dist = to_t.length()
    d = to_t.normalized()
    forward = ship_fwd.dot(d)                # cos of the angle
    c = ship_fwd.cross(d)
    side = "left" if c > 1e-6 else "right" if c < -1e-6 else "none"
    in_cone = forward >= cos_half and dist <= RANGE
    print(f"{name} {target}: dist={dist:6.1f} cos={forward:+.3f} "
          f"angle={math.degrees(math.acos(forward)):5.1f} turn={side:5} in_cone={in_cone}")

print("\n== 2. closing speed: project relative velocity onto line of sight ==")
ship_vel = V2(30, 0)
for name, pos, vel in [("rock", V2(100, 0), V2(-10, 0)),
                       ("drone", V2(100, 0), V2(0, 40)),
                       ("probe", V2(100, 0), V2(35, 0))]:
    los = (pos - ship_pos).normalized()
    closing = (ship_vel - vel).dot(los)      # >0 means the gap is shrinking
    print(f"{name:5}: closing speed {closing:+6.1f} m/s")

print("\n== 3. local to world: a point and a direction transform differently ==")
def to_world_point(local, pos, angle_deg):
    c, s = math.cos(math.radians(angle_deg)), math.sin(math.radians(angle_deg))
    return V2(local.x * c - local.y * s, local.x * s + local.y * c) + pos

def to_world_dir(local, angle_deg):
    c, s = math.cos(math.radians(angle_deg)), math.sin(math.radians(angle_deg))
    return V2(local.x * c - local.y * s, local.x * s + local.y * c)  # no translation

muzzle_local = V2(4, 1)            # gun barrel, in ship coordinates
muzzle_dir_local = V2(1, 0)
for pos, ang in [(V2(0, 0), 0), (V2(10, 5), 90), (V2(10, 5), 135)]:
    print(f"ship at {pos} heading {ang:3d}: muzzle point {to_world_point(muzzle_local, pos, ang)}"
          f"  muzzle dir {to_world_dir(muzzle_dir_local, ang)}"
          f"  wrong dir (translated) {to_world_point(muzzle_dir_local, pos, ang)}")

print("\n== 4. hitting a wall: keep the tangential part ==")
wall_normal = V2(-1, 1).normalized()          # a 45 deg wall
v = V2(20, 0)
into = v.dot(wall_normal)
slide = v - wall_normal * into if into < 0 else v
print(f"v={v} n={wall_normal} v.n={into:.2f} -> slide={slide} |slide|={slide.length():.2f}")

print("\n== 5. zero vector ==")
print("normalized((0,0)) ->", V2(0, 0).normalized())
print(f"Python {sys.version.split()[0]}")
== 1. in front, to the side, or out of range ==
A (80.00, 20.00): dist=  82.5 cos=+0.970 angle= 14.0 turn=left  in_cone=True
B (40.00, 60.00): dist=  72.1 cos=+0.555 angle= 56.3 turn=left  in_cone=False
C (-50.00, 5.00): dist=  50.2 cos=-0.995 angle=174.3 turn=left  in_cone=False
D (150.00, 0.00): dist= 150.0 cos=+1.000 angle=  0.0 turn=none  in_cone=False

== 2. closing speed: project relative velocity onto line of sight ==
rock : closing speed  +40.0 m/s
drone: closing speed  +30.0 m/s
probe: closing speed   -5.0 m/s

== 3. local to world: a point and a direction transform differently ==
ship at (0.00, 0.00) heading   0: muzzle point (4.00, 1.00)  muzzle dir (1.00, 0.00)  wrong dir (translated) (1.00, 0.00)
ship at (10.00, 5.00) heading  90: muzzle point (9.00, 9.00)  muzzle dir (0.00, 1.00)  wrong dir (translated) (10.00, 6.00)
ship at (10.00, 5.00) heading 135: muzzle point (6.46, 7.12)  muzzle dir (-0.71, 0.71)  wrong dir (translated) (9.29, 5.71)

== 4. hitting a wall: keep the tangential part ==
v=(20.00, 0.00) n=(-0.71, 0.71) v.n=-14.14 -> slide=(10.00, 10.00) |slide|=14.14

== 5. zero vector ==
normalized((0,0)) -> None
Python 3.13.16

B is within range, but at 56.3° it lies outside the 30° half-angle. D is dead ahead but 150 away, beyond range. A field-of-view check needs both the angle and the distance, and they come from different operations. D also shows the steering edge case: the cross product is exactly 0 and the result is none. A plain > 0 test would have put a target straight ahead on the right and turned the ship for nothing.

In the closing-speed block, the drone moves at right angles to the line of sight, so it contributes nothing and the 30 m/s comes entirely from our own ship. The probe is faster than us, so its closing speed is negative and the gap is growing.

The first transform result is the trap. With the ship at the origin and a heading of 0°, transforming a direction as if it were a point still gives the right answer, because the added position is zero. Testing only at the origin hides the bug; you have to move the ship to see it.

After the wall, speed drops from 20 to 14.14 and the ship moves along the surface. What was removed is the normal component, which also has length 14.14. The two components are perpendicular, so it is Pythagoras rather than subtraction: √(20² - 14.14²) ≈ 14.14.

How those positions and velocities change from one frame to the next is the subject of the next post, Same Ship, Different Flight at 30 and 144 FPS.

Notes

  • Position minus position is displacement, displacement over its length is direction, and velocity is displacement per second. They look identical in code, so keep track of which is which.
  • Transform a position by rotating and then translating; transform a direction by rotating only. Testing at the origin will not catch the mistake.
  • The dot product of two unit vectors is the cosine of the angle between them, so a view cone check compares it with a precomputed cosine. Projection is a dot product too.
  • The sign of the 2D cross product gives left or right. Handle zero separately, and remember that a y-down screen swaps the sides.
  • To slide along a wall, subtract the velocity’s component along the normal; subtract it twice to bounce. Check for a zero vector before normalising.

Further reading

  • Godot Engine, Vector math: dot products for view checks, why a zero vector can’t be normalised, and the reflection behind bounce(). Official tutorial.
  • Unity, Transform.TransformDirection: how direction transforms ignore position and scale, and how they differ from TransformPoint. Official API reference.
  • Wikipedia, Dot product and Cross product: definitions and geometric meaning. Useful for the formulas, not as a guide to any engine’s behaviour.
  • Ernest Adams and Joris Dormans, Game Mechanics: Advanced Game Design, chapter 1: the split between physics and economy mechanics, and between continuous and discrete ones. A game design textbook that the later posts in this series also lean on.

Ideas and technical judgement by Sheng; drafted with Claude · examples run on Python 3.13.16.