Skip to content

Points, Vectors and Directions

Two different things that are both written as two numbers: a place and a displacement. Which combinations of them mean something, which one is the most used line of code in any game, and the test that tells them apart when the naming doesn’t.

“The player is at (3,2)(3, 2).” That is a point — a place, measured from the origin.

“Move 4 right and 1 up.” That is a vector — a displacement. How far and which way, with no opinion about where you started.

In code both are { x, y }. TypeScript sees no difference between them and neither does any engine. The compiler will not catch you mixing them up, which is exactly why it is worth being deliberate about which one you are holding.

placeplace=displacementplace+displacement=placedisplacement+displacement=displacementplace+place=nothing at all\begin{aligned} \text{place} - \text{place} &= \text{displacement} \\ \text{place} + \text{displacement} &= \text{place} \\ \text{displacement} + \text{displacement} &= \text{displacement} \\ \text{place} + \text{place} &= \text{nothing at all} \end{aligned}

The first one is the workhorse. The displacement from A to B is B - A, and it is probably the single most written line in game code: from a player to a target, from a bullet to a wall, from a camera to the thing it is following.

towards the target=targetplayer\text{towards the target} = \text{target} - \text{player}

Get the order backwards and you get (8,4)(-8, -4) instead of (8,4)(8, 4) — a displacement of exactly the same size pointing exactly the wrong way. It compiles, it runs, and the enemy runs away from you.

The last one is the interesting one, and the reason this Section exists.

Why is adding two places meaningless? Not because a rule says so. Because the answer depends on where you happen to have put the origin, which means it was never a fact about the two places.

Two places, the arrow between them, and an origin that moves
Drag either place, or pick one with the buttons and use the sliders.
The code that draws it src/lib/gamedev/demos/2d/arrow.scene.ts
/** Two places and the displacement between them, with an origin you can move out from under both. */
import {
  makeCanvas2D,
  addDragTargets,
  arrow,
  dot,
  label,
  line,
} from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addSlider, addReadout, addButtonRow } from "../controls.ts";
import {
  pixelsPerUnit,
  screenToWorld,
  worldToScreen,
} from "../../../gamedev2d/screen.ts";
import {
  START_A,
  START_B,
  VIEW,
  WORLD_HEIGHT,
  readings,
} from "./arrow-shared.ts";
import type { MountFn } from "../runner.ts";

const GRID = "#252b33";
const ORIGIN = "#f0883e";
const PLACE = "#d2a8ff";
const BETWEEN = "#39d3c3";
const TEXT = "#9198a1";

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

  let a = { ...START_A };
  let b = { ...START_B };
  let picked = 0;

  const show = addReadout(el);
  const note = addReadout(el);
  const mark = addButtonRow(el, [
    { label: "move A", apply: () => pick(0) },
    { label: "move B", apply: () => pick(1) },
  ]);
  const px = addSlider(
    el,
    "x of the picked place",
    0,
    16,
    START_A.x,
    fromSliders,
    "",
    0.1,
  );
  const py = addSlider(
    el,
    "y of the picked place",
    0,
    8.7,
    START_A.y,
    fromSliders,
    "",
    0.1,
  );
  const shift = addSlider(
    el,
    "slide the origin sideways",
    -6,
    6,
    0,
    draw,
    " units",
    0.5,
  );

  function pick(which: number) {
    picked = which;
    const p = which === 0 ? a : b;
    px.set(p.x);
    py.set(p.y);
    draw();
  }

  function fromSliders() {
    const p = { x: px(), y: py() };
    if (picked === 0) a = p;
    else b = p;
    draw();
  }

  // Dragging is a convenience; the sliders above are the accessible path to the same values.
  const stopDragging = addDragTargets(
    canvas,
    () => [worldToScreen(a, VIEW), worldToScreen(b, VIEW)],
    (index, x, y) => {
      const world = screenToWorld({ x, y }, VIEW);
      const clamped = {
        x: Math.min(Math.max(world.x, 0), 16),
        y: Math.min(Math.max(world.y, 0), WORLD_HEIGHT),
      };
      if (index === 0) a = clamped;
      else b = clamped;
      picked = index;
      px.set(clamped.x);
      py.set(clamped.y);
      draw();
    },
  );

  function draw() {
    clear();
    const scale = pixelsPerUnit(VIEW);
    const origin = { x: shift(), y: 0 };

    for (let u = 0; u <= 16; u += 1) {
      const x = u * scale;
      line(ctx, { x, y: 0 }, { x, y: height }, GRID, { width: 1 });
    }
    for (let v = 0; v <= Math.ceil(WORLD_HEIGHT); v += 1) {
      const y = height - v * scale;
      line(ctx, { x: 0, y }, { x: width, y }, GRID, { width: 1 });
    }

    // The origin everything is measured from, drawn where it currently sits.
    const originAt = worldToScreen(origin, VIEW);
    arrow(ctx, originAt, { x: originAt.x + 52, y: originAt.y }, ORIGIN, 1.4);
    arrow(ctx, originAt, { x: originAt.x, y: originAt.y - 52 }, ORIGIN, 1.4);
    dot(ctx, originAt.x, originAt.y, 4, ORIGIN);
    label(ctx, "origin", originAt.x + 6, originAt.y + 14, ORIGIN);

    const r = readings(a, b, origin);
    const aAt = worldToScreen(a, VIEW);
    const bAt = worldToScreen(b, VIEW);

    // Each place, as a measurement from the origin.
    for (const [p, at, name] of [
      [r.a, aAt, "A"],
      [r.b, bAt, "B"],
    ] as const) {
      line(ctx, originAt, at, PLACE, { dashed: true, width: 1 });
      dot(ctx, at.x, at.y, 6, PLACE);
      label(
        ctx,
        `${name} (${p.x.toFixed(1)}, ${p.y.toFixed(1)})`,
        at.x + 10,
        at.y - 8,
        PLACE,
      );
    }

    // The displacement, which belongs to neither place.
    arrow(ctx, aAt, bAt, BETWEEN, 2.4);
    label(
      ctx,
      `B - A = (${r.between.x.toFixed(1)}, ${r.between.y.toFixed(1)})`,
      (aAt.x + bAt.x) / 2 + 8,
      (aAt.y + bAt.y) / 2 + 16,
      BETWEEN,
    );
    label(ctx, "drag either place, or use the sliders", 8, 16, TEXT);

    mark(picked);
    show(
      `A and B read (${r.a.x.toFixed(1)}, ${r.a.y.toFixed(1)}) and (${r.b.x.toFixed(1)}, ${r.b.y.toFixed(1)}) \u00B7 ` +
        `the arrow between them is (${r.between.x.toFixed(1)}, ${r.between.y.toFixed(1)})`,
    );
    note(
      `slide the origin and both places get new numbers, the arrow keeps its own \u00B7 ` +
        `A + B would be (${r.sum.x.toFixed(1)}, ${r.sum.y.toFixed(1)}), which moves too, which is why it means nothing`,
    );
  }

  draw();

  return stopDragging;
};

export default mount;

Drag A and B around, then slide the origin out from under them.

Watch what happens. Both places get new numbers. They haven’t moved — the origin did — but every reading changes, because a place is measured from somewhere.

The arrow between them does not change. Same length, same direction, same two numbers. It was never measured from anywhere; it only ever said how far and which way.

That is the real difference between the two, and it is worth more than any naming convention:

a displacement survives moving the origin. A place does not.\text{a displacement survives moving the origin. A place does not.}
Which combinations mean something, and which only look like they do
The code src/lib/gamedev/demos/2d/kinds.ts
/** Which combinations of places and displacements mean something, decided by moving the origin. */
import {
  addPositions,
  combine,
  displacement,
  fromNewOrigin,
  midpoint,
  movedBy,
} from "../../../gamedev2d/vectors2d.ts";
import type { Demo } from "../runner.ts";

const A = { x: 3, y: 2 };
const B = { x: 11, y: 6 };
const at = (p: { x: number; y: number }) => `(${p.x}, ${p.y})`;

/** Re-measure from a shifted origin, do the sum, then put the answer back for comparison. */
const throughOrigin = (
  origin: { x: number; y: number },
  f: (a: typeof A, b: typeof B) => { x: number; y: number },
) => movedBy(f(fromNewOrigin(A, origin), fromNewOrigin(B, origin)), origin);

const demo: Demo = (log) => {
  log(
    `A is at ${at(A)} and B is at ${at(B)}`,
    "",
    "both measured from the origin",
  );
  log(
    "B - A, a place minus a place",
    `${at(displacement(A, B))}, a displacement`,
  );
  log(
    "A + that displacement",
    `${at(movedBy(A, displacement(A, B)))}, a place`,
    "which is B again",
  );
  log(
    "two displacements added",
    `${at(combine({ x: 4, y: 1 }, { x: -1, y: 3 }))}, a displacement`,
    "and the order they are applied in makes no difference",
  );

  // The test: move the origin, and see which answers stay put.
  for (const origin of [
    { x: 5, y: 5 },
    { x: -100, y: 40 },
  ]) {
    log(
      `measured from ${at(origin)} instead, B - A is`,
      at(throughOrigin(origin, displacement)),
      origin.x === 5
        ? "unchanged, because it never depended on the origin"
        : undefined,
    );
  }
  for (const origin of [
    { x: 5, y: 5 },
    { x: -100, y: 40 },
  ]) {
    log(
      `but A + B, measured from ${at(origin)}, becomes`,
      at(throughOrigin(origin, addPositions)),
      origin.x === 5
        ? "a different place each time, so it is not about A and B at all"
        : undefined,
    );
  }
  log(
    "the midpoint of A and B survives it though",
    `${at(throughOrigin({ x: -100, y: 40 }, midpoint))} from any origin`,
    "because it is really A plus half a displacement",
  );
};

export default demo;
A is at (3, 2) and B is at (11, 6) // both measured from the origin
B - A, a place minus a place (8, 4), a displacement
A + that displacement (11, 6), a place // which is B again
two displacements added (3, 4), a displacement // and the order they are applied in makes no difference
measured from (5, 5) instead, B - A is (13, 9) // unchanged, because it never depended on the origin
measured from (-100, 40) instead, B - A is (-92, 44)
but A + B, measured from (5, 5), becomes (9, 3) // a different place each time, so it is not about A and B at all
but A + B, measured from (-100, 40), becomes (114, -32)
the midpoint of A and B survives it though (7, 4) from any origin // because it is really A plus half a displacement

The numbers make it concrete. Measured from three different origins, B - A is (8,4)(8, 4) every single time. Meanwhile A + B goes from (14,8)(14, 8) to (4,2)(4, -2) to (214,72)(214, -72) — three different answers to the same question, which is how you know it wasn’t a question.

The check sweeps 400 origins and requires the displacement to be identical at every one, and the sum to differ at essentially all of them. A claim like that shouldn’t rest on the one origin a figure happened to use.

Multiplying a displacement does what you would hope: doubling it means going twice as far the same way, and multiplying by 1-1 turns it around. Scaling also survives the origin test, because it is built out of a displacement and nothing else.

Multiplying a place by two does not mean anything useful. It doubles the distance from the origin, so the result depends entirely on where the origin is — the check shows the same point landing at (6,4)(6, 4) from one origin and (1,1)(1, -1) from another.

If you ever find yourself scaling a position, what you almost certainly want is to scale the displacement from something and then apply it: anchor + (p - anchor) * k. Same shape as the midpoint, same reason it works.

source Places, displacements, and the operations that mean something src/lib/gamedev2d/vectors2d.ts 94 lines
/**
 * Places and displacements. Two different things, both written as two numbers.
 *
 * A **point** is a place: "the player is at (3, 2)". A **vector** is a displacement: "move 4 right
 * and 1 up". They are stored identically, which is exactly why mixing them up is so easy and why it
 * is worth being deliberate about which one you are holding.
 *
 * The test that actually separates them is **what happens when the origin moves**. Shift the origin
 * and every point gets a different pair of numbers, because a point is measured *from* somewhere. A
 * displacement is unchanged, because it was never measured from anywhere - it only ever said how far
 * and which way. That is the whole distinction, and it is checked rather than asserted.
 */

/** A place, measured from the origin. */
export type Point = { x: number; y: number };

/**
 * A displacement: how far and which way, with no home.
 *
 * Note this is the *same shape* as `Point`, and TypeScript treats the two as interchangeable. The
 * compiler will not catch you passing one where the other belongs. The distinction is real, but it
 * lives in your head and in your naming, not in the type checker.
 */
export type Vector = { x: number; y: number };

/**
 * **Point minus point is a vector.** The displacement that takes you from `from` to `to`.
 *
 * This is the single most used operation in any game, and the order is the thing people get wrong.
 * `to - from` points at the target. `from - to` points directly away from it - which is a bug that
 * looks like an enemy fleeing when it was supposed to chase.
 */
export function displacement(from: Point, to: Point): Vector {
  return { x: to.x - from.x, y: to.y - from.y };
}

/** **Point plus vector is a point.** Take a place, apply a displacement, arrive somewhere. */
export function movedBy(p: Point, v: Vector): Point {
  return { x: p.x + v.x, y: p.y + v.y };
}

/**
 * **Vector plus vector is a vector.** Two displacements one after the other, as one displacement.
 *
 * Order does not matter: walking east then north lands you where walking north then east does. Which
 * sounds obvious said aloud and is worth knowing you can rely on.
 */
export function combine(a: Vector, b: Vector): Vector {
  return { x: a.x + b.x, y: a.y + b.y };
}

/** Stretch or shrink a displacement. Negative `k` turns it around. */
export function scaled(v: Vector, k: number): Vector {
  return { x: v.x * k, y: v.y * k };
}

/** The same displacement, backwards. */
export function reversed(v: Vector): Vector {
  return { x: -v.x, y: -v.y };
}

/**
 * The point halfway between two points.
 *
 * Adding two points is meaningless, so this looks like it should be illegal - and written as
 * `(a + b) / 2` it is a coincidence rather than a reason. Written the honest way it is fine:
 *
 * ```
 * midpoint = a + (b - a) / 2
 * ```
 *
 * which is a point, plus half of a displacement. That is a legitimate sentence, and it happens to
 * give the same answer. **Averaging points is the one case where the arithmetic accidentally works**,
 * because the weights add up to one - so it survives an origin shift where a plain sum does not.
 */
export function midpoint(a: Point, b: Point): Point {
  return movedBy(a, scaled(displacement(a, b), 0.5));
}

/**
 * Adding two points, which is the mistake this Section exists to name.
 *
 * It is here only so the demo can show what it produces. The result depends entirely on where the
 * origin happens to be, which means it is not a fact about the two places at all - move the origin
 * and the "answer" moves somewhere else. Nothing in a game should ever want this.
 */
export function addPositions(a: Point, b: Point): Point {
  return { x: a.x + b.x, y: a.y + b.y };
}

/** Every point re-measured from a new origin. Nothing has moved; the numbers have. */
export function fromNewOrigin(p: Point, newOrigin: Point): Point {
  return { x: p.x - newOrigin.x, y: p.y - newOrigin.y };
}
  • Chasing, aiming and following, all of which start with target - self.
  • Anything that moves, which is a place plus a displacement every single frame.
  • Combining influences — input plus knockback plus wind — which is displacements adding.
  • Cameras, which follow a place while thinking in displacements from it.
  • Section 1.3, which takes the displacement from here and asks how long it is.
  • Section 1.4, which asks what angle it makes.