Skip to content

Rotating a Point and Turning Smoothly

Two lines of arithmetic that turn a point about the origin, the third step needed to turn it about anything else, and the reason that third step is the most commonly forgotten line in 2D game code. Then turning toward a heading rather than snapping to it, which is where Section 2.2’s wrap earns its place.

x′=xcos⁡θ−ysin⁡θy′=xsin⁡θ+ycos⁡θx' = x\cos\theta - y\sin\theta \qquad y' = x\sin\theta + y\cos\theta

Counter-clockwise, about the origin, in world coordinates with Y up.

Worth reading rather than memorising, because it is not arbitrary. Ask where the two axes go:

(1,0)  ⟼  (cos⁡θ,  sin⁡θ)(0,1)  ⟼  (−sin⁡θ,  cos⁡θ)(1, 0) \;\longmapsto\; (\cos\theta,\; \sin\theta) \qquad (0, 1) \;\longmapsto\; (-\sin\theta,\; \cos\theta)

The first is the unit circle from Section 2.2. The second is that direction given a quarter turn, which Section 2.1 showed costs nothing: swap and negate. Now read the formula again — it is xx lots of the first plus yy lots of the second. A rotation moves the axes and lets the point ride along.

That observation is the whole of Section 3.1, where those two destinations become the two columns of a matrix. Nothing new will be needed there; it is this, written down differently.

The bug to know about is a sign. Writing xcos⁡θ+ysin⁡θx\cos\theta + y\sin\theta for the first component rotates the other way, and on screen a rotation going the wrong way looks like a rotation right up until something has to line up with something else. The build checks that rotating and then unrotating returns the original, and that a 90°90° rotation is exactly the free quarter turn from Section 2.1.

Two properties fall out that are worth relying on. Length is preserved — checked at every degree around the circle, drift under 10−1210^{-12}. And rotations add: turning by 30°30° then 40°40° is turning by 70°70°, which is why a spinning object can accumulate an angle rather than a matrix.

The formula only turns things about the origin. For anything else, three steps:

  1. Subtract the pivot, so the pivot is at the origin.
  2. Rotate.
  3. Add the pivot back.
One sprite, one pivot, and the translate that gets forgotten
The code that draws it src/lib/gamedev/demos/2d/pivot.scene.ts
/** A sprite rotated about a pivot, with a checkbox that drops the final translate back. */
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 {
  addButtonRow,
  addCheckbox,
  addReadout,
  addSlider,
} from "../controls.ts";
import { distance, length } from "../../../gamedev2d/length2d.ts";
import {
  PIVOT_RANGE,
  SPRITE,
  SPRITE_CENTRE,
  SPRITE_TIP,
  fittingScale,
  missBy,
  orbitCentre,
  transformed,
} from "./pivot-shared.ts";
import type { MountFn } from "../runner.ts";

const BEFORE = "#484f58";
const AFTER = "#7ee787";
const WRONG = "#ff7b72";
const PIVOT = "#f0883e";
const GRID = "#252b33";
const TEXT = "#9198a1";

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

  const show = addReadout(el);
  const note = addReadout(el);
  const angle = addSlider(el, "angle", -180, 180, 40, draw);
  const preset = addButtonRow(el, [
    { label: "pivot at the origin", apply: () => place(0, 0) },
    {
      label: "pivot at the sprite's centre",
      apply: () => place(SPRITE_CENTRE.x, SPRITE_CENTRE.y),
    },
  ]);
  const pivotX = addSlider(
    el,
    "pivot x",
    PIVOT_RANGE.minX,
    PIVOT_RANGE.maxX,
    SPRITE_CENTRE.x,
    draw,
    "",
    0.1,
  );
  const pivotY = addSlider(
    el,
    "pivot y",
    PIVOT_RANGE.minY,
    PIVOT_RANGE.maxY,
    SPRITE_CENTRE.y,
    draw,
    "",
    0.1,
  );
  const translateBack = addCheckbox(
    el,
    "translate back afterwards (uncheck for the bug)",
    true,
    draw,
  );

  function place(x: number, y: number) {
    pivotX.set(x);
    pivotY.set(y);
    draw();
  }

  function outline(
    points: Array<{ x: number; y: number }>,
    colour: string,
    lineWidth = 2,
    dashed = false,
  ) {
    ctx.save();
    ctx.strokeStyle = colour;
    ctx.lineWidth = lineWidth;
    if (dashed) ctx.setLineDash([4, 4]);
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
  }

  function draw() {
    clear();
    // Derived from the geometry, not chosen: see `fittingScale`. A hand-picked scale put the
    // rotated shape off the canvas at an eighth of the slider settings.
    const unit = fittingScale(width / 2, height / 2);
    const ox = width / 2;
    const oy = height / 2;
    // 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 }, GRID, { width: 1 });
    line(ctx, { x: ox, y: 0 }, { x: ox, y: height }, GRID, { width: 1 });
    label(ctx, "origin", ox + 6, oy + 14, TEXT);

    const pivot = { x: pivotX(), y: pivotY() };
    const back = translateBack();
    const result = transformed(angle(), pivot, back);
    const miss = missBy(angle(), pivot);
    const displaced = length(miss) > 1e-6;

    outline(SPRITE.map(at), BEFORE);
    label(ctx, "before", at(SPRITE[0]).x - 6, at(SPRITE[0]).y + 16, BEFORE);

    // What the shape is actually turning around, which is the origin when the last step is missing.
    const centre = at(orbitCentre(pivot, back));
    const radius = distance(orbitCentre(pivot, back), result[3]) * unit;
    ctx.save();
    ctx.strokeStyle = back ? PIVOT : WRONG;
    ctx.setLineDash([3, 3]);
    ctx.beginPath();
    ctx.arc(centre.x, centre.y, radius, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();

    // With the bug on, show where it should have landed, so the arrow joins two visible things.
    if (!back && displaced) {
      const correct = transformed(angle(), pivot, true).map(at);
      outline(correct, AFTER, 1, true);
      label(ctx, "should be here", correct[3].x + 8, correct[3].y - 6, AFTER);
      arrow(ctx, at(result[0]), correct[0], WRONG, 1.4);
    }

    outline(result.map(at), back ? AFTER : WRONG);

    // The pivot. When the translate happens it is a fixed point: it does not move at all.
    const pin = at(pivot);
    fillDot(ctx, pin.x, pin.y, 5, PIVOT);
    label(ctx, "pivot", pin.x + 8, pin.y - 8, PIVOT);

    // Only one of the two buttons can be describing the current pivot, and usually neither is.
    const near = (p: { x: number; y: number }) =>
      Math.abs(pivot.x - p.x) < 1e-9 && Math.abs(pivot.y - p.y) < 1e-9;
    preset(near({ x: 0, y: 0 }) ? 0 : near(SPRITE_CENTRE) ? 1 : -1);

    show(
      `rotating by ${angle()}\u00B0 about (${pivot.x.toFixed(1)}, ${pivot.y.toFixed(1)}) \u00B7 ` +
        `${back ? "subtract the pivot, rotate, add it back" : "subtract the pivot, rotate, and stop there"}`,
    );
    note(
      back
        ? `the pivot is the one place that does not move, and the tip stays ${distance(pivot, SPRITE_TIP).toFixed(2)} units ` +
            `from it at every angle \u2014 that is the dashed circle`
        : !displaced
          ? "with the pivot at the origin there is nothing to add back, so the broken version is exactly right"
          : `it rotated correctly and landed (${miss.x.toFixed(1)}, ${miss.y.toFixed(1)}) away, which is the pivot itself \u00B7 ` +
            `the dashed circle has the same radius, ${distance(pivot, SPRITE_TIP).toFixed(2)}, and is round the origin instead \u2014 ` +
            `that is what it turned about`,
    );
  }

  draw();

  return () => {};
};

export default mount;

The dashed circle is the arrow’s tip keeping its distance from the pivot, which is what rotating about a point means. The pivot itself never moves.

Now uncheck the box, which drops step 3.

Nothing errors. Nothing is NaN. The sprite rotates perfectly correctly and lands somewhere else, displaced by exactly the pivot — which the build asserts over 5,766 pivot-and-angle combinations rather than eyeballing on one. The faint outline is where it should have been.

Watch what happens to the dashed circle when you uncheck the box. It keeps its radius and changes its centre, from the pivot to the origin. That is the most precise statement of the bug available: the shape is still turning, still rigid, still sweeping the same circle — around the wrong point. Both halves of that are asserted, because a circle drawn in the wrong place is exactly the kind of claim a picture makes silently.

Then click pivot at the origin with the box still unchecked, and watch the bug vanish.

That is the whole reason this is worth a section rather than a footnote. With the pivot at the origin there is nothing to add back, so the broken version is exactly right. Which is where things sit while you are writing the code, and while you are testing it, and right up until the first time an artist moves a sprite off the origin or you parent something to something else. The failure arrives later than the mistake, and by then the rotation code looks like the part that already works.

A near relative of this shows up in Section 3.1 as transform order, and again in Section 3.2 where a turret on a tank has to rotate about its own mount rather than about the world’s origin.

The Angle Difference, and Why It Needs Wrapping

Section titled “The Angle Difference, and Why It Needs Wrapping”

Facing 170°170°, aiming at −170°-170°. How far do you have to turn?

Subtracting says −170−170=−340°-170 - 170 = -340°. The honest answer is 20°20°, the other way. Both describe the same final heading, which is exactly why this bug is so quiet: nothing is wrong with −340°-340° except that it is the long way round.

Δ=wrap⁡(θtarget−θcurrent)\Delta = \operatorname{wrap}(\theta_{\text{target}} - \theta_{\text{current}})

One subtraction and one wrap, and the wrap is the entire function. The sign that comes out says which way to turn: positive is counter-clockwise. That sign is the same information Section 2.1’s cross product carries, arriving by a different route.

Snapping an angle to its target reads as teleportation. Real machinery has a turn rate: move toward the target by at most so much per step, and stop when you arrive.

A turret turning toward a heading, the short way and the long way
Push the steps slider up and count, then uncheck the wrap and push it again.
The code that draws it src/lib/gamedev/demos/2d/turn.scene.ts
/** A turret turning toward a heading at a fixed rate, with the wrap on the difference as a checkbox. */
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 { directionFromAngle, toDegrees } from "../../../gamedev2d/angles2d.ts";
import { START, simulate, stepsToArrive } from "./turn-shared.ts";
import type { MountFn } from "../runner.ts";

const NOW = "#7ee787";
const LONG = "#ff7b72";
const TARGET = "#d2a8ff";
const GHOST = "#484f58";
const GRID = "#252b33";
const TEXT = "#9198a1";

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

  const show = addReadout(el);
  const note = addReadout(el);
  const steps = addSlider(el, "steps taken", 0, 90, 0, draw, " steps");
  const target = addSlider(el, "target heading", -180, 180, -170, draw);
  const rate = addSlider(el, "turn rate", 1, 30, 4, draw, "\u00B0 per step");
  const wrap = addCheckbox(
    el,
    "wrap the difference (uncheck for the bug)",
    true,
    draw,
  );

  function draw() {
    clear();
    const radius = 108;
    const ox = width / 2;
    const oy = height / 2;
    // A heading is an angle in the world, so drawing it negates the angle, not the coordinates.
    const on = (angle: number, r: number) => ({
      x: ox + Math.cos(angle) * r,
      y: oy - Math.sin(angle) * r,
    });

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

    const t = simulate(target(), rate(), steps(), wrap());
    const needed = stepsToArrive(target(), rate(), wrap());
    const colour = wrap() ? NOW : LONG;

    ctx.save();
    ctx.strokeStyle = GRID;
    ctx.beginPath();
    ctx.arc(ox, oy, radius, 0, Math.PI * 2);
    ctx.stroke();
    ctx.restore();

    // Every heading visited so far, as a trail. This is what "the long way round" looks like.
    for (const angle of t.angles) {
      const p = on(angle, radius);
      fillDot(ctx, p.x, p.y, 2, colour);
    }

    // Where it started, where it is aiming, and where it currently points.
    arrow(ctx, { x: ox, y: oy }, on(START, radius * 0.82), GHOST, 1.4);
    label(
      ctx,
      `start ${toDegrees(START).toFixed(0)}\u00B0`,
      on(START, radius * 0.95).x,
      on(START, radius * 0.95).y - 6,
      GHOST,
      "center",
    );

    const aim = (target() * Math.PI) / 180;
    line(ctx, { x: ox, y: oy }, on(aim, radius * 1.1), TARGET, {
      dashed: true,
      width: 1.5,
    });
    label(
      ctx,
      `target ${target()}\u00B0`,
      on(aim, radius * 1.24).x,
      on(aim, radius * 1.24).y + 4,
      TARGET,
      "center",
    );

    const facing = directionFromAngle(t.current);
    arrow(
      ctx,
      { x: ox, y: oy },
      { x: ox + facing.x * radius, y: oy - facing.y * radius },
      colour,
      3,
    );
    fillDot(ctx, ox, oy, 7, TEXT);
    label(
      ctx,
      `${toDegrees(t.current).toFixed(0)}\u00B0`,
      ox + facing.x * (radius + 22),
      oy - facing.y * (radius + 22) + 4,
      colour,
      "center",
    );

    show(
      `difference used ${toDegrees(t.difference).toFixed(0)}\u00B0 at ${rate()}\u00B0 per step \u00B7 ` +
        `${needed === null ? "never arrives" : `arrives after ${needed} steps`} \u00B7 ` +
        `now at ${toDegrees(t.current).toFixed(0)}\u00B0 after ${steps()}`,
    );
    note(
      wrap()
        ? "the wrapped difference is the short way round, so the turret never goes the long way to reach a nearby heading"
        : `unwrapped, ${toDegrees(START).toFixed(0)}\u00B0 to ${target()}\u00B0 reads as ${toDegrees(t.difference).toFixed(0)}\u00B0 \u2014 ` +
            `it still arrives, which is why this looks like an AI problem rather than an arithmetic one`,
    );
  }

  draw();

  return () => {};
};

export default mount;

The turret starts at 170°170° and the target defaults to −170°-170°, 20°20° away across the seam. Push the steps slider up and count.

Then uncheck the wrap and push it again. At the default 4°4° per step:

VersionDifference it computesSteps to arrive
wrapped, the short way20°20°5
unwrapped, the long way−340°-340°85

Seventeen times the work to reach a heading that was 20°20° away, and both numbers are checked. It still arrives, which is the point worth dwelling on. Nothing hangs, nothing oscillates, no value becomes NaN. The turret simply takes the scenic route whenever the short way happens to cross the ±180°\pm 180° seam — which means it looks like a bug in the AI’s decision-making rather than a missing call to wrap, and that is where people go looking.

The correct version is checked for three things beyond the step count: it never overshoots, it settles exactly on the target instead of oscillating around it, and its first step goes the direction the wrapped difference says. Sweeping six targets against five turn rates, the unwrapped version is never faster and is slower in most of them.

source Rotation, a pivot, and turning the short way src/lib/gamedev2d/rotate2d.ts 137 lines
/**
 * Turning things: the rotation formula, rotating about a pivot that is not the origin, and turning
 * toward a heading the short way at a limited rate.
 *
 * Three separate mistakes live in this file's subject matter, and each one is here as a working
 * function beside its broken twin. Rotating without moving the pivot to the origin first. Comparing
 * two angles without wrapping the difference. Interpolating between two headings as though they were
 * plain numbers.
 */
import { wrapRadians } from "./angles2d.ts";
import { displacement, movedBy, type Point, type Vector } from "./vectors2d.ts";

/**
 * Rotate a vector counter-clockwise about the origin.
 *
 * $$x' = x\cos\theta - y\sin\theta \qquad y' = x\sin\theta + y\cos\theta$$
 *
 * Worth reading rather than memorising. The result is the vector's own components used as weights on
 * two rotated axes: $(\cos\theta, \sin\theta)$ is where $(1, 0)$ ends up, and
 * $(-\sin\theta, \cos\theta)$ is where $(0, 1)$ ends up. Section 3.1 turns exactly that observation
 * into a matrix, and the two columns of it are those two vectors.
 *
 * The classic slip is a sign: $x\cos\theta + y\sin\theta$ rotates the other way, which looks
 * plausible on screen right up to the moment something has to line up with something else.
 */
export function rotate(v: Vector, radians: number): Vector {
  const c = Math.cos(radians);
  const s = Math.sin(radians);
  return { x: v.x * c - v.y * s, y: v.x * s + v.y * c };
}

/**
 * Rotate a point about an arbitrary pivot. Three steps, and the third is the one that gets forgotten.
 *
 * The formula above only turns things about the origin, so: measure the point **from** the pivot,
 * rotate that displacement, then put it back. Subtract, rotate, add.
 *
 * Leaving off the final add does not crash and does not produce a `NaN`. The shape rotates correctly
 * and lands somewhere else, offset by exactly the pivot. Worse, **it is completely correct while the
 * pivot is at the origin**, so it survives every test you write before you move anything.
 */
export function rotateAbout(p: Point, pivot: Point, radians: number): Point {
  return movedBy(pivot, rotate(displacement(pivot, p), radians));
}

/** The same, minus the step back. Kept only so the build can show what it costs. */
export function rotateAboutBroken(
  p: Point,
  pivot: Point,
  radians: number,
): Point {
  return rotate(displacement(pivot, p), radians);
}

/**
 * Rotate a whole shape about a pivot, computing the sine and cosine **once**.
 *
 * The arithmetic is identical to calling `rotateAbout` per point; the difference is that a sprite with
 * forty corners calls `Math.cos` once instead of forty times. Not a micro-optimisation worth
 * contorting code for, but this shape is the natural one anyway.
 */
export function rotateAll(
  points: readonly Point[],
  pivot: Point,
  radians: number,
): Point[] {
  const c = Math.cos(radians);
  const s = Math.sin(radians);
  return points.map((p) => {
    const dx = p.x - pivot.x;
    const dy = p.y - pivot.y;
    return { x: pivot.x + dx * c - dy * s, y: pivot.y + dx * s + dy * c };
  });
}

/**
 * The shortest signed way round from one heading to another, in $[-\pi, \pi)$.
 *
 * $$\Delta = \operatorname{wrap}(\theta_{\text{target}} - \theta_{\text{current}})$$
 *
 * One subtraction and one wrap. **The wrap is the entire function** - without it, facing $170°$ and
 * aiming at $-170°$ gives a difference of $-340°$, so a turret turns almost all the way round to
 * reach a target $20°$ away. The sign says which way: positive is counter-clockwise.
 */
export function angleDifference(current: number, target: number): number {
  return wrapRadians(target - current);
}

/**
 * Turn from `current` toward `target` by at most `maxStep`, taking the short way.
 *
 * The clamp is what makes it feel like a machine with a turn rate rather than a value being
 * assigned. Note that it snaps exactly onto the target on the final step instead of overshooting and
 * oscillating, which is the other half of what makes it look intentional.
 */
export function turnToward(
  current: number,
  target: number,
  maxStep: number,
): number {
  const difference = angleDifference(current, target);
  if (Math.abs(difference) <= maxStep) return wrapRadians(target);
  return wrapRadians(current + Math.sign(difference) * maxStep);
}

/**
 * The same turn with the wrap left out, which turns the long way round. Do not ship this.
 *
 * It still arrives, which is what makes it survive a code review: nothing is `NaN`, nothing
 * oscillates, the turret simply takes the scenic route whenever the short way crosses the $\pm 180°$
 * seam. On a tank turret it reads as a bug in the AI rather than in the arithmetic.
 */
export function turnTowardBroken(
  current: number,
  target: number,
  maxStep: number,
): number {
  const difference = target - current;
  if (Math.abs(difference) <= maxStep) return target;
  return current + Math.sign(difference) * maxStep;
}

/**
 * Interpolate between two headings the short way round.
 *
 * Blend the **difference**, not the endpoints: take the wrapped difference, scale it, and add. At
 * `t = 1` this returns the target's heading rather than the target's number, which is the same
 * direction and not always the same value.
 */
export function lerpAngle(from: number, to: number, t: number): number {
  return wrapRadians(from + angleDifference(from, to) * t);
}

/** Lerping the raw numbers, which is the bug. Halfway between two nearby headings can be opposite. */
export function lerpAngleBroken(from: number, to: number, t: number): number {
  return from + (to - from) * t;
}
  • Anything that faces a direction: turrets, vehicles, characters, a sprite’s rotation field.
  • Rotating a sprite about a point that is not its origin — a wheel, a hinge, a swinging platform.
  • Turning smoothly toward a target, which is what makes a movement feel mechanical rather than instant.
  • Matrices in Section 3.1, which package exactly this alongside translation and scale.
  • Local space in Section 3.2, where a child’s rotation composes with its parent’s.
  • Angle interpolation in Section 4.2, which is the wrapped difference with an easing curve on it.