Length, Distance and Normalizing
What You’ll Learn
Section titled “What You’ll Learn”How long a displacement is, how far apart two places are, and how to throw away the length while keeping the direction. Then the most common movement bug in 2D games, which is all three of those ideas going wrong at once.
Length Is the Pythagorean Theorem
Section titled “Length Is the Pythagorean Theorem”A displacement of is 3 across and 4 up. Those two legs and the displacement itself make a right triangle, so the length is the hypotenuse:
That is the whole idea. is 5 long. There is nothing further to learn about magnitude in 2D.
Distance is the same formula, one step later. The distance between two places is the length of the displacement between them, and Section 1.2 already gave us that displacement:
One idea, not two. Which also means distance doesn’t care which way round you ask, because reversing a displacement doesn’t change how long it is.
The Diagonal Speed Bug
Section titled “The Diagonal Speed Bug”Here is the bug, and it has shipped in a great many games.
Read the keyboard. Right adds to x, up adds to y. Multiply by your speed, add it to the position, done. It works. Then someone holds two keys at once, and moves faster.
src/lib/gamedev/demos/2d/diagonal.scene.ts /** Every direction a player can hold, drawn as the square you get and the circle you wanted. */
import { makeCanvas2D, 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 } from "../controls.ts";
import {
circleLoop,
rawAt,
fixedAt,
speedRatio,
squareLoop,
} from "./diagonal-shared.ts";
import type { MountFn } from "../runner.ts";
const RAW = "#ff7b72";
const FIXED = "#39d3c3";
const AXIS = "#30363d";
const TEXT = "#9198a1";
const mount: MountFn = (el) => {
const { ctx, width, height, clear } = makeCanvas2D(el, 320);
const show = addReadout(el);
const note = addReadout(el);
const heading = addSlider(el, "direction held", 0, 360, 45, draw);
function draw() {
clear();
const cx = width / 2;
const cy = height / 2;
const unit = 96;
// World y is up, so drawing negates it. Section 1.1's one conversion, in one place.
const at = (v: { x: number; y: number }) => ({
x: cx + v.x * unit,
y: cy - v.y * unit,
});
line(ctx, { x: cx - 150, y: cy }, { x: cx + 150, y: cy }, AXIS);
line(ctx, { x: cx, y: cy - 150 }, { x: cx, y: cy + 150 }, AXIS);
// The two reachable sets: the square raw input traces, the circle normalizing gives.
const trace = (loop: Array<{ x: number; y: number }>, colour: string) => {
ctx.save();
ctx.strokeStyle = colour;
ctx.lineWidth = 1.6;
ctx.beginPath();
loop.forEach((v, i) => {
const p = at(v);
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
});
ctx.stroke();
ctx.restore();
};
trace(squareLoop(), RAW);
trace(circleLoop(), FIXED);
const radians = (heading() * Math.PI) / 180;
const raw = rawAt(radians);
const fixed = fixedAt(radians);
const ratio = speedRatio(radians);
arrow(ctx, { x: cx, y: cy }, at(raw), RAW, 2.4);
arrow(ctx, { x: cx, y: cy }, at(fixed), FIXED, 2.4);
dot(ctx, at(raw).x, at(raw).y, 4, RAW);
dot(ctx, at(fixed).x, at(fixed).y, 4, FIXED);
label(ctx, "raw input", 10, 18, RAW);
label(ctx, `reaches the square, length ${ratio.toFixed(3)}`, 10, 32, TEXT);
label(ctx, "normalized", 10, 52, FIXED);
label(ctx, "reaches the circle, length 1.000", 10, 66, TEXT);
show(
`holding ${heading()}\u00B0: raw input is ${ratio.toFixed(3)} long, normalized is 1.000 \u00B7 ` +
`${((ratio - 1) * 100).toFixed(1)}% faster than intended`,
);
note(
ratio > 1.4
? "a full diagonal is the worst case: root two, so 41% faster than walking along an axis"
: ratio < 1.001
? "along an axis the two agree exactly, which is why this bug hides during testing"
: "anywhere off an axis the raw version is already too fast",
);
}
draw();
return () => {};
};
export default mount; Sweep the slider all the way round and watch the two arrow tips.
The red tip traces a square. Each axis is held or not, independently, so the furthest input you can produce is the corner — and that corner is long, not 1.
The teal tip traces a circle, because it was normalized first. Length 1 at every angle.
| Direction held | Raw input | Its length | Too fast by |
|---|---|---|---|
| 0° (right) | 1.000 | 0% | |
| 15° | 1.035 | 3.5% | |
| 30° | 1.155 | 15.5% | |
| 45° | 1.414 | 41.4% |
The reason this survives testing is the first row. Along an axis the buggy version is exactly correct. You walk right, the speed is right. You walk up, the speed is right. Only diagonals are wrong, and 41% is fast enough to feel unfair without being obviously broken.
Normalizing Is the Fix
Section titled “Normalizing Is the Fix”Divide a displacement by its own length and you get a unit vector — same direction, length exactly 1:
Which turns movement into two separate decisions, and that separation is the real prize:
Direction says which way. Speed says how fast. Neither one can leak into the other. The input square becomes the input circle, and 41% goes to 0%.
Skip the Square Root
Section titled “Skip the Square Root”Square roots are the expensive part of all of this, and comparisons never need one.
“Is the enemy within 5 meters?” You are comparing a distance against a radius. Both are lengths, so both are non-negative, and squaring cannot reorder non-negative numbers. So square the radius instead of rooting the distance:
Same answer, always. Not an approximation and not a heuristic — the two tests agree at every point on the plane, which is why this is safe rather than merely fast.
src/lib/gamedev/demos/2d/range.ts /** Range checks without a square root, and the two ways to get them wrong. */
import {
distance,
distanceSquared,
isWithin,
normalize,
velocityFrom,
} from "../../../gamedev2d/length2d.ts";
import type { Demo } from "../runner.ts";
const PLAYER = { x: 3, y: 2 };
const RADIUS = 5;
const demo: Demo = (log) => {
const target = { x: 5, y: 5 };
log(
`from (3, 2) to (5, 5): distance and squared distance`,
`${distance(PLAYER, target).toFixed(4)} and ${distanceSquared(PLAYER, target)}`,
"the second one skipped a square root",
);
log(
`is it within ${RADIUS}? asked both ways`,
`by distance ${distance(PLAYER, target) < RADIUS}, by squared ${distanceSquared(PLAYER, target) < RADIUS * RADIUS}`,
`which is what isWithin does: ${isWithin(PLAYER, target, RADIUS)}`,
);
// The claim that the two agree is worth sweeping rather than sampling.
let disagreements = 0;
for (let i = 0; i < 200; i += 1) {
for (let j = 0; j < 200; j += 1) {
const p = { x: -10 + i * 0.1, y: -10 + j * 0.1 };
if (
distance(PLAYER, p) < RADIUS !==
distanceSquared(PLAYER, p) < RADIUS * RADIUS
) {
disagreements += 1;
}
}
}
log(
"over 40,000 positions the two tests disagree",
`${disagreements} times`,
"so the root is simply wasted work",
);
// The trap: a squared distance compared against an unsquared radius.
log(
`the trap: squared distance < ${RADIUS} instead of < ${RADIUS * RADIUS}`,
`${distanceSquared(PLAYER, target) < RADIUS}`,
`wrongly excludes a target only ${distance(PLAYER, target).toFixed(2)} away`,
);
// The other guard: a direction that does not exist.
log(
"normalize({ x: 0, y: 0 })",
`${normalize({ x: 0, y: 0 })}`,
"no direction to report, so it says so",
);
log(
"the same divide left unguarded",
`{ x: ${0 / 0}, y: ${0 / 0} }`,
"and a NaN position is a sprite that has silently vanished",
);
log(
"velocityFrom({ x: 0, y: 0 }, 5)",
`{ x: ${velocityFrom({ x: 0, y: 0 }, 5).x}, y: ${velocityFrom({ x: 0, y: 0 }, 5).y} }`,
"no input means standing still, which is the sensible answer here",
);
};
export default demo; The trap is on the fourth row, and it is worth staring at. A target at is meters from a player at , so it is comfortably inside a radius of 5. But its squared distance is 13, and is false.
The check rejects a target that is well within range, and it will do it silently. No error, no NaN — just an enemy that never notices you. A squared distance must be compared against a squared threshold, and forgetting to square the radius is the one way to get this wrong.
The Zero-Length Guard
Section titled “The Zero-Length Guard”Normalizing divides by the length. So what happens when the length is zero?
And a NaN position is the worst kind of bug, because it does not stop. It spreads: add
anything to NaN and you get NaN, so the position stays broken forever, and every
comparison against it returns false. The sprite simply vanishes and the console says
nothing.
A displacement of zero length has no direction. Not a default one, not “right” — none.
So normalize returns null and makes the caller say what they meant:
- For input, no keys held means stand still.
velocityFromreturns . - For aiming, on top of the target means keep the current facing.
- For a surface normal, it means the geometry is degenerate and something upstream is wrong.
Three different right answers, which is exactly why the function shouldn’t pick one for you.
source Length, distance, and the two guards worth having
/**
* How long a displacement is, how far apart two places are, and how to keep only the direction.
*
* All of it is the Pythagorean theorem. The interesting parts are the two places where the obvious
* code is wrong: taking a square root you did not need, and normalizing a displacement that has no
* direction to keep.
*/
import type { Point, Vector } from "./vectors2d.ts";
/**
* Length, by Pythagoras. The hypotenuse of the triangle the two components make.
*
* $$|v| = \sqrt{v_x^2 + v_y^2}$$
*
* `Math.hypot` is the same formula written for you, and it is careful about enormous and tiny
* numbers in a way that `Math.sqrt(x*x + y*y)` is not - squaring $10^{200}$ overflows to infinity
* while `hypot` gets it right. Games rarely hold numbers like that, so use whichever reads better.
*/
export function length(v: Vector): number {
return Math.hypot(v.x, v.y);
}
/**
* Length **without the square root**, which is what you almost always want for a comparison.
*
* Square root is the expensive part, and comparing lengths never needs it: both sides are lengths,
* so both are non-negative, and squaring preserves the order. "Is this closer than that" and "is
* this within range" are the same answer either way, one square root cheaper.
*
* The trap is that a squared length is not a length. Compare it against a **squared** threshold, or
* the numbers are nonsense - and it is nonsense that looks plausible, because a radius of 5 becomes
* a radius of 25 rather than an error.
*/
export function lengthSquared(v: Vector): number {
return v.x * v.x + v.y * v.y;
}
/** How far apart two places are. The length of the displacement between them. */
export function distance(a: Point, b: Point): number {
return Math.hypot(b.x - a.x, b.y - a.y);
}
/** The same, minus the square root, for when you are only comparing. */
export function distanceSquared(a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
return dx * dx + dy * dy;
}
/**
* Is `b` within `radius` of `a`? The range check, done without a square root.
*
* Squaring the radius instead of rooting the distance gives an identical answer, which is worth
* seeing rather than trusting: both quantities are non-negative, and squaring is increasing on
* non-negative numbers, so it cannot reorder them.
*/
export function isWithin(a: Point, b: Point, radius: number): boolean {
return distanceSquared(a, b) < radius * radius;
}
/**
* The same direction, at length 1. A **unit vector**.
*
* Returns `null` when there is no direction to report, which is the guard this function exists for.
* A displacement of zero length has no direction - not a default one, not "right", none - and
* dividing by its length produces `NaN`, which then spreads into every number it touches and
* surfaces as a sprite that has vanished rather than as an error.
*
* Returning `null` forces the caller to decide what "no input" means, which is a decision they
* should be making anyway.
*/
export function normalize(v: Vector, epsilon = 1e-9): Vector | null {
const len = length(v);
if (len < epsilon) return null;
return { x: v.x / len, y: v.y / len };
}
/** The same direction at a chosen length. Direction and speed as separate decisions. */
export function withLength(v: Vector, target: number): Vector | null {
const unit = normalize(v);
return unit === null ? null : { x: unit.x * target, y: unit.y * target };
}
/**
* A velocity from a direction and a speed, which is the shape movement code should have.
*
* Passing raw input straight in as a velocity is **the diagonal speed bug**: two keys held at once
* gives a displacement of length $\sqrt{2}$, so the player moves 41% faster diagonally than along an
* axis. Normalizing first is the whole fix, and it is one line.
*/
export function velocityFrom(input: Vector, speed: number): Vector {
const unit = normalize(input);
return unit === null
? { x: 0, y: 0 }
: { x: unit.x * speed, y: unit.y * speed };
}
/**
* Where full deflection in a given direction lands, for input clamped per axis to $\pm 1$.
*
* This traces the **square** that keyboard-style input can reach: each axis is independently held or
* not, so the corner is $(1, 1)$ and its length is $\sqrt{2}$. That square is the bug, drawn.
*/
export function fullDeflection(radians: number): Vector {
const x = Math.cos(radians);
const y = Math.sin(radians);
const biggest = Math.max(Math.abs(x), Math.abs(y));
// The ray at this angle, stretched until it meets the edge of the square.
return biggest < 1e-12 ? { x: 0, y: 0 } : { x: x / biggest, y: y / biggest };
} The build-time check sweeps every angle in quarter-degree steps and requires the normalized version to be length 1 at all of them. Sampling would not do: the buggy version is correct along the axes, so a handful of tidy test values is precisely the set that misses it. It also compares the rooted and squared range tests at 40,000 positions and requires zero disagreements, because “these two are always the same” is a claim, not a convention.
Where This Shows Up
Section titled “Where This Shows Up”- Every movement loop, which is a direction and a speed kept apart.
- Range checks — aggro radius, pickup radius, explosion radius — all cheaper squared.
- Nearest-target searches, which compare many distances and need none of the roots.
- Section 1.4, where the dot product needs unit vectors to give an angle back.
- Section 2.2, which takes a direction and asks what angle it makes.