Skip to content

The Dot Product

One number, built from two multiplications and an add, that answers “do these two directions agree?” It does three jobs that look unrelated: a facing test, a vision cone, and splitting a vector into two pieces. Then the two ways it goes wrong, both of which ship regularly.

Multiply the matching components and add the results:

ab=axbx+aybya \cdot b = a_x b_x + a_y b_y

That is the computation. This is what it means:

ab=abcosθa \cdot b = |a|\,|b|\cos\theta

Nothing in the code computes an angle, and yet the answer contains one. That gap is the whole reason the dot product is everywhere in game code: it is the cheap way to ask an expensive question.

Read the second form and everything else follows. The lengths are always positive, so the sign comes entirely from cosθ\cos\theta:

Angle between themcosθ\cos\thetaDot productMeaning
0°11largestsame direction
under 90°90°positivepositiveroughly agree
exactly 90°90°00zeroperpendicular
over 90°90°negativenegativeroughly oppose
180°180°1-1most negativeopposite

“Is the enemy in front of me?” Dot your facing with the displacement to the enemy. Positive means yes.

This is the cheapest useful thing in the whole track. No normalizing, no square root, no trigonometry — lengths cannot change a sign, only a magnitude, so scaling either vector leaves the answer alone.

The trap is what “in front” means here. Positive dot means within 90°, which is a 180°180° wedge — half the entire plane. Something directly at your shoulder passes this test. The build-time check counts it: of 360 directions around the guard, the sign test accepts 181 of them.

If you wanted a narrow cone, you need a threshold, not a sign.

That count should be 180, and the extra one is worth a moment. At exactly ±90°\pm 90° the dot product comes out as 2.4×10162.4 \times 10^{-16} rather than 00, because Math.cos(Math.PI / 2) is not exactly zero. So both perpendicular directions land on the positive side. Exactly perpendicular is decided by rounding, which is fine here — nothing depends on which way a knife edge falls — but it is worth knowing before you write a test that expects a dot product to equal zero.

Normalize both directions and the lengths drop out of abcosθ|a||b|\cos\theta, leaving the cosine of the angle by itself. So compare it against the cosine of the angle you actually want:

f^t^cosϕ\hat{f} \cdot \hat{t} \ge \cos\phi

where ϕ\phi is the cone’s half-angle. Still no acos. You wanted to know whether an angle is small enough, and comparing cosines answers that without ever producing the angle.

One thing about this runs backwards from intuition. Cosine falls as the angle grows, so a wider cone is a smaller threshold:

Half-angleThresholdCone is
15°15°0.966very narrow
45°45°0.707typical guard
60°60°0.500generous
90°90°0.000the sign test

The last row is worth noticing: the sign test isn’t a different technique, it is this one with the cone opened to its widest.

A guard's vision cone, and the same test with the normalize removed
Hold the target angle at 60° and sweep the distance with the checkbox off.
The code that draws it src/lib/gamedev/demos/2d/cone.scene.ts
/** A guard's vision cone, with a checkbox that removes the normalize and breaks it. */
import {
  makeCanvas2D,
  arrow,
  dot as fillDot,
  label,
  line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import { GUARD, RANGE, report, targetAt } from "./cone-shared.ts";
import type { MountFn } from "../runner.ts";

const CONE = "#7ee787";
const MISS = "#ff7b72";
const AXIS = "#30363d";
const TEXT = "#9198a1";

const mount: MountFn = (el) => {
  const { ctx, width, height, clear } = makeCanvas2D(el, 300);

  const show = addReadout(el);
  const note = addReadout(el);
  const half = addSlider(el, "cone half-angle", 5, 90, 45, draw);
  const bearing = addSlider(el, "target angle off facing", -180, 180, 60, draw);
  const distance = addSlider(el, "target distance", 1, 10, 3, draw, " m", 0.5);
  const normalized = addCheckbox(
    el,
    "normalize first (uncheck for the bug)",
    true,
    draw,
  );

  function draw() {
    clear();
    // The guard sits left of centre so the whole cone fits when it is wide.
    const ox = 90;
    const oy = height / 2;
    const unit = 24;
    // World y is up, so drawing negates it. Section 1.1's one conversion, in one place.
    const at = (p: { x: number; y: number }) => ({
      x: ox + p.x * unit,
      y: oy - p.y * unit,
    });

    line(ctx, { x: 0, y: oy }, { x: width, y: oy }, AXIS);
    line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, AXIS);

    const r = report(half(), bearing(), distance(), normalized());
    const colour = r.seen ? CONE : MISS;

    // The cone as a filled wedge, drawn in canvas angles, so both edges are negated.
    const edge = (half() * Math.PI) / 180;
    ctx.save();
    ctx.fillStyle = r.seen
      ? "rgba(126, 231, 135, 0.16)"
      : "rgba(255, 123, 114, 0.12)";
    ctx.beginPath();
    ctx.moveTo(ox, oy);
    ctx.arc(ox, oy, RANGE * unit, -edge, edge);
    ctx.closePath();
    ctx.fill();
    ctx.restore();

    // The range limit, so a target failing on distance rather than angle is legible.
    ctx.save();
    ctx.strokeStyle = AXIS;
    ctx.setLineDash([4, 4]);
    ctx.beginPath();
    ctx.arc(ox, oy, RANGE * unit, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();

    // The guard, and the direction it is facing.
    arrow(ctx, at(GUARD), at({ x: 2.4, y: 0 }), TEXT, 2);
    fillDot(ctx, at(GUARD).x, at(GUARD).y, 5, TEXT);
    label(ctx, "guard", at(GUARD).x - 6, at(GUARD).y + 20, TEXT, "center");

    // The target, and the displacement the test measures.
    const target = at(r.target);
    line(ctx, at(GUARD), target, colour, { dashed: !r.seen });
    fillDot(ctx, target.x, target.y, 5, colour);
    label(
      ctx,
      r.seen ? "seen" : r.inRange ? "outside the cone" : "out of range",
      target.x + 9,
      target.y + 4,
      colour,
    );

    // The cone edges, labelled, since the wedge alone does not say what its angle is.
    for (const sign of [-1, 1]) {
      const e = targetAt(sign * half(), RANGE);
      line(ctx, at(GUARD), at(e), CONE, { width: 1 });
    }
    label(ctx, `half-angle ${half()}\u00B0`, 10, 18, CONE);
    label(ctx, `range ${RANGE} m`, 10, 32, TEXT);

    show(
      `${normalized() ? "dot of unit directions" : "dot of the raw displacement"} ` +
        `${r.measured.toFixed(3)} vs threshold ${r.threshold.toFixed(3)} \u2192 ` +
        `${r.inCone ? "inside" : "outside"} the cone, ${r.seen ? "seen" : "not seen"}`,
    );
    note(
      normalized()
        ? `at ${Math.abs(bearing())}\u00B0 off the facing the answer does not change with distance`
        : `without the normalize the number grows with distance: at ${distance().toFixed(1)} m it is ` +
            `${r.measured.toFixed(2)}, so moving further away makes the guard more likely to "see" you`,
    );
  }

  draw();

  return () => {};
};

export default mount;

Sweep the target’s angle with the checkbox left on, and the answer changes exactly where the cone edge is. Sweep the distance and nothing changes at all — as it should not, because distance is not an angle.

Now uncheck the box.

Forgetting to Normalize Is the Bug Worth Remembering

Section titled “Forgetting to Normalize Is the Bug Worth Remembering”

With the normalize removed, the code compares the raw dot product against the threshold. That dot product is dcosθd\cos\theta, where dd is the distance — so it grows as the target walks away.

Hold the angle at 60°60°, well outside a 45°45° cone, and watch:

DistanceRaw dotPasses 0.707\ge 0.707?The honest answer
1.21.2 m0.600nono
22 m1.000yesno
55 m2.500yesno
1212 m6.000yesno

The threshold has stopped meaning an angle. Solving dcos60°cos45°d\cos 60° \ge \cos 45° says the false positives begin at exactly 2\sqrt{2} meters, which the check pins to 12 decimal places.

And it fails in the least helpful direction possible. Close in it is correct, so it survives testing in a corridor. Far away it is wrong, and the further away the target gets the more certain the guard becomes that it can see them. A bug that improves under close inspection is the kind that reaches players.

The correct version is verified against a genuinely different piece of arithmetic: an acos-based angle comparison, at 57,600 positions, with zero disagreements and both answers appearing. Two implementations agreeing is worth more than one implementation matching my expectations.

Same formula, different question. How far along a direction does this vector reach?

along=vdd\text{along} = \frac{v \cdot d}{|d|}

And when dd is already unit length the division vanishes, so the projection is the dot product. That is the main practical reason to keep unit vectors around.

One vector split into the part along a direction and the part across it
The code that draws it src/lib/gamedev/demos/2d/project.scene.ts
/** A vector's shadow on a direction, split into the part along it and the part across it. */
import {
  makeCanvas2D,
  arrow,
  dot as fillDot,
  label,
  line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addReadout, addSlider } from "../controls.ts";
import { split, vectorAt } from "./project-shared.ts";
import type { MountFn } from "../runner.ts";

const V = "#58a6ff";
const ALONG = "#7ee787";
const ACROSS = "#d2a8ff";
const DIR = "#f0883e";
const AXIS = "#30363d";
const TEXT = "#9198a1";

const mount: MountFn = (el) => {
  const { ctx, width, height, clear } = makeCanvas2D(el, 300);

  const show = addReadout(el);
  const note = addReadout(el);
  const vAngle = addSlider(el, "the vector's angle", -180, 180, 55, draw);
  const speed = addSlider(el, "its length", 1, 6, 4, draw, "", 0.5);
  const dirAngle = addSlider(el, "the direction's angle", -180, 180, 0, draw);

  function draw() {
    clear();
    const ox = width / 2;
    const oy = height / 2 + 20;
    const unit = 32;
    // World y is up, so drawing negates it. Section 1.1's one conversion, in one place.
    const at = (p: { x: number; y: number }) => ({
      x: ox + p.x * unit,
      y: oy - p.y * unit,
    });

    line(ctx, { x: 0, y: oy }, { x: width, y: oy }, AXIS);
    line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, AXIS);

    const s = split(vAngle(), speed(), dirAngle());

    // The line the projection lands on, drawn right across the picture in both directions.
    const far = vectorAt(dirAngle(), 20);
    line(ctx, at({ x: -far.x, y: -far.y }), at(far), DIR, { dashed: true });
    arrow(ctx, at({ x: 0, y: 0 }), at(s.direction), DIR, 2);
    label(ctx, "direction, length 1", 10, 18, DIR);

    // The vector, then its two parts.
    arrow(ctx, at({ x: 0, y: 0 }), at(s.v), V, 2.6);
    arrow(ctx, at({ x: 0, y: 0 }), at(s.alongPart), ALONG, 2.6);
    // The across part starts where the along part ended, so the two visibly add up to the vector.
    arrow(ctx, at(s.alongPart), at(s.v), ACROSS, 2);
    fillDot(ctx, at(s.alongPart).x, at(s.alongPart).y, 4, ALONG);

    label(ctx, `the vector, length ${speed().toFixed(1)}`, 10, 32, V);
    label(ctx, `along  ${s.signed.toFixed(2)}`, 10, 46, ALONG);
    label(
      ctx,
      `across ${Math.hypot(s.acrossPart.x, s.acrossPart.y).toFixed(2)}`,
      10,
      60,
      ACROSS,
    );

    show(
      `dot ${s.raw.toFixed(3)} \u2192 the vector reaches ${s.signed.toFixed(3)} along the direction, ` +
        `${s.signed < 0 ? "which is backwards along it" : "measured from the origin"}`,
    );
    note(
      Math.abs(s.signed) < 0.02
        ? "at a right angle the projection is zero: the vector goes nowhere along the direction"
        : s.signed < 0
          ? "a negative projection means the vector points the other way along the line"
          : "the two parts always add back to the vector, and they are always at a right angle to each other",
    );
  }

  draw();

  return () => {};
};

export default mount;

The green arrow is the part of the vector that goes along the direction. The purple arrow is everything left over. Notice two things as you move the sliders:

  • The two parts always add back to the original vector. Exactly, not approximately.
  • They are always at a right angle to each other, which is why their lengths satisfy Pythagoras with the original.

At 90°90° the green arrow disappears: the vector goes nowhere along the direction. Past 90°90° it flips and the number goes negative, which is the sign test again, arriving from a different door.

This one split does more work later than anything else in Part 1. Sliding along a wall is throwing away the part that goes into the wall and keeping the part that goes along it. Section 5.4 is mostly this picture.

The Angle, and the Clamp That Is Not Optional

Section titled “The Angle, and the Clamp That Is Not Optional”

If you genuinely need the angle, rearrange the cosine form:

θ=arccos ⁣(abab)\theta = \arccos\!\left(\frac{a \cdot b}{|a|\,|b|}\right)

Math.acos is only defined on [1,1][-1, 1]. Outside that it returns NaN. And rounding will put you outside it.

The clamp before acos, and what leaving it out costs
The code src/lib/gamedev/demos/2d/acos.ts
/** The clamp before acos, and how often leaving it out costs you a NaN. */
import {
  angleBetweenDegrees,
  dot,
  unclampedAngle,
} from "../../../gamedev2d/dot2d.ts";
import { normalize } from "../../../gamedev2d/length2d.ts";
import type { Demo } from "../runner.ts";

const demo: Demo = (log) => {
  // A real unit direction whose dot with itself lands just above 1. Nothing exotic produced it.
  const u = normalize({
    x: Math.cos(0.0000314) * 3.7,
    y: Math.sin(0.0000314) * 3.7,
  })!;

  log(
    "a normalized direction, dotted with itself",
    `${dot(u, u)}`,
    "which is not 1",
  );
  log(
    "Math.acos of that",
    `${unclampedAngle(u, u)}`,
    "outside [-1, 1] acos has no answer",
  );
  log(
    "the same angle, clamped first",
    `${angleBetweenDegrees(u, u)!.toFixed(1)}\u00B0`,
    "a vector is at 0 degrees to itself, as it should be",
  );

  // How often it happens, which is the part that decides whether the clamp is optional.
  let nans = 0;
  const samples = 200000;
  for (let i = 0; i < samples; i += 1) {
    const r = (i / samples) * Math.PI * 2;
    const d = normalize({ x: Math.cos(r) * 3.7, y: Math.sin(r) * 3.7 })!;
    if (Number.isNaN(unclampedAngle(d, d))) nans += 1;
  }
  log(
    `over ${samples.toLocaleString("en-US")} directions, unclamped acos returns NaN`,
    `${nans.toLocaleString("en-US")} times`,
    `${((nans / samples) * 100).toFixed(1)}% of them, so this is not a rare case`,
  );

  // And why a NaN is worse than a wrong number: it does not stay put.
  log(
    "what a NaN angle does next",
    `${NaN > 0} and ${NaN < 0}`,
    "every comparison says false",
  );
  log(
    "and it spreads",
    `${NaN + 1}`,
    "so one missing clamp corrupts the whole frame",
  );
};

export default demo;
a normalized direction, dotted with itself 1.0000000000000002 // which is not 1
Math.acos of that NaN // outside [-1, 1] acos has no answer
the same angle, clamped first 0.0° // a vector is at 0 degrees to itself, as it should be
over 200,000 directions, unclamped acos returns NaN 60,461 times // 30.2% of them, so this is not a rare case
what a NaN angle does next false and false // every comparison says false
and it spreads NaN // so one missing clamp corrupts the whole frame

Normalize a direction, dot it with itself, and you get 1.0000000000000002. That is a legitimate floating-point result and an impossible cosine, so acos gives up.

How often? Over 200,000 directions swept around a circle, the unclamped version returns NaN 60,461 times — 30.2%. This is not an edge case to guard against out of habit. It is the common case, and the situation that triggers it most reliably is the most innocent one imaginable: asking for the angle between a direction and itself.

So clamp before you call it:

const cosine = dot(a, b) / (length(a) * length(b));
return Math.acos(Math.min(1, Math.max(-1, cosine)));

A NaN is worse than a wrong number, which is why this matters more than the size of the error suggests. A wrong angle gives you a visibly odd result you can chase. A NaN compares false against everything — NaN > 0 and NaN < 0 are both false — so every guard clause silently lets it through, and it spreads to every number it touches.

source The dot product, and the three questions it answers src/lib/gamedev2d/dot2d.ts 151 lines
/**
 * The dot product: one number that answers "how much do these two agree about direction".
 *
 * It does three jobs that look unrelated until you see they are the same formula: a facing test, a
 * projection, and the angle between two directions. The traps are all in the third one.
 */
import { length, normalize } from "./length2d.ts";
import type { Point, Vector } from "./vectors2d.ts";

/**
 * Multiply matching components, add the results. That is all it is.
 *
 * $$a \cdot b = a_x b_x + a_y b_y$$
 *
 * The reason it means anything is the other way of writing the same number:
 *
 * $$a \cdot b = |a|\,|b|\cos\theta$$
 *
 * Two multiplications and an add on the left; an angle on the right. Nothing in the code computes
 * an angle, which is exactly why this is the cheap way to ask about one.
 */
export function dot(a: Vector, b: Vector): number {
  return a.x * b.x + a.y * b.y;
}

/**
 * Is `b` in the same general direction as `a`? The sign test.
 *
 * Positive means the angle between them is under 90°, zero means exactly perpendicular, negative
 * means over 90°. **Lengths cannot change the sign**, only the size, so this one question needs no
 * normalizing at all - which makes it the cheapest useful thing here.
 *
 * The trap is thinking "positive means in front of me". It means within 90° of your facing, which is
 * a **180° wedge** - half the plane. Something at your shoulder passes this test.
 */
export function isInFront(facing: Vector, toTarget: Vector): boolean {
  return dot(facing, toTarget) > 0;
}

/**
 * The cosine threshold for a cone of a given half-angle, so a cone test needs no `acos`.
 *
 * Compare a normalized dot product against this. Note the direction of the comparison is the
 * opposite of what a beginner expects: **a wider cone is a smaller number**, because cosine falls
 * as the angle grows. A 180° cone (half-angle 90°) has a threshold of 0, which is the sign test.
 */
export function coneThreshold(halfAngleDegrees: number): number {
  return Math.cos((halfAngleDegrees * Math.PI) / 180);
}

/**
 * Can a guard at `eye` facing `facing` see `target`, given a cone and a range?
 *
 * Two things this gets right that the obvious version does not. The displacement to the target is
 * **normalized** first, so the answer depends only on the angle - skip that and the dot grows with
 * distance, so a far-away target passes a cone it is nowhere near. And the range check is squared,
 * so the whole test contains no square root except the one inside the normalize.
 */
export function canSee(
  eye: Point,
  facing: Vector,
  target: Point,
  halfAngleDegrees: number,
  range: number,
): boolean {
  const toTarget = { x: target.x - eye.x, y: target.y - eye.y };
  if (dot(toTarget, toTarget) > range * range) return false;
  const unit = normalize(toTarget);
  const forward = normalize(facing);
  // Standing exactly on the guard has no direction, so there is no angle to judge. Seen.
  if (unit === null) return true;
  if (forward === null) return false;
  return dot(forward, unit) >= coneThreshold(halfAngleDegrees);
}

/**
 * How far along `onto` the vector `v` reaches, as a signed number. The **scalar projection**.
 *
 * $$\text{along} = \frac{v \cdot d}{|d|}$$
 *
 * When `onto` is already a unit vector the division disappears and the projection is just the dot
 * product - which is the main reason unit vectors are worth keeping around.
 */
export function along(v: Vector, onto: Vector): number {
  const len = length(onto);
  return len < 1e-9 ? 0 : dot(v, onto) / len;
}

/**
 * The part of `v` that lies along `onto`, as a vector. The **vector projection**.
 *
 * $$\text{proj}_d\,v = \frac{v \cdot d}{d \cdot d}\,d$$
 *
 * Dividing by `d · d` rather than `|d|` is not a trick; it is the scalar projection divided by the
 * length a second time, which turns `d` into a unit vector without a separate normalize.
 */
export function projectOnto(v: Vector, onto: Vector): Vector {
  const denominator = dot(onto, onto);
  if (denominator < 1e-18) return { x: 0, y: 0 };
  const k = dot(v, onto) / denominator;
  return { x: onto.x * k, y: onto.y * k };
}

/**
 * Split `v` into the part along `onto` and the part across it.
 *
 * The two always add back to `v`, and they are always perpendicular to each other. This one split is
 * how sliding along a wall works, how a jump's horizontal and vertical parts stay independent, and
 * how you separate the speed you keep from the speed you lose in a collision.
 */
export function decompose(
  v: Vector,
  onto: Vector,
): { along: Vector; across: Vector } {
  const parallel = projectOnto(v, onto);
  return {
    along: parallel,
    across: { x: v.x - parallel.x, y: v.y - parallel.y },
  };
}

/**
 * The angle between two vectors in radians, from 0 to $\pi$. Never signed - see Section 2.1.
 *
 * $$\theta = \arccos\!\left(\frac{a \cdot b}{|a|\,|b|}\right)$$
 *
 * `Math.acos` is only defined on $[-1, 1]$ and returns `NaN` outside it. Rounding puts you outside
 * it: normalizing two vectors and dotting them can land on `1.0000000000000002`, which is a real
 * floating-point result and an impossible cosine. **The clamp is not defensive coding, it is
 * required**, and the value it saves you from is a `NaN` angle that then poisons everything downstream.
 */
export function angleBetween(a: Vector, b: Vector): number | null {
  const la = length(a);
  const lb = length(b);
  if (la < 1e-9 || lb < 1e-9) return null;
  const cosine = dot(a, b) / (la * lb);
  return Math.acos(Math.min(1, Math.max(-1, cosine)));
}

/** The same in degrees, because that is what a designer will ask you for. */
export function angleBetweenDegrees(a: Vector, b: Vector): number | null {
  const radians = angleBetween(a, b);
  return radians === null ? null : (radians * 180) / Math.PI;
}

/**
 * `acos` with no clamp, kept only so the build can show what it costs. Do not ship this.
 */
export function unclampedAngle(a: Vector, b: Vector): number {
  return Math.acos(dot(a, b) / (length(a) * length(b)));
}
  • Vision cones and aggro checks, which are one comparison and no trigonometry.
  • Facing tests — is this behind me, is this wall pointing at me, is the door on my side.
  • Sliding along walls, which is the split with one part discarded. Section 5.4.
  • Lighting, where surface brightness is a dot product of a normal and a light direction.
  • Section 2.1, the cross product, which supplies the “which side” the dot product cannot.