Chapter 5
Math for games
Practical applications for sine waves, pseudo-randomness, angles, and more.
Diagonal movement
4-way movement is easy. When you press → then player.x += speed * dt. When you press ← then player.x -= speed * dt. Then repeat for the y axis.
function fixedUpdate(dt: number) {
const speed = 80;
if (keysDown.has("ArrowUp")) {
player.y -= speed * dt;
} else if (keysDown.has("ArrowDown")) {
player.y += speed * dt;
} else if (keysDown.has("ArrowLeft")) {
player.x -= speed * dt;
} else if (keysDown.has("ArrowRight")) {
player.x += speed * dt;
}
}To support 8-way movement (where → and ↑ at the same time move diagonally), that should be easy, too, right?
Wrong.
function fixedUpdate(dt: number) {
const speed = 80;
if (keysDown.has("ArrowUp")) player.y -= speed * dt;
if (keysDown.has("ArrowDown")) player.y += speed * dt;
if (keysDown.has("ArrowLeft")) player.x -= speed * dt;
if (keysDown.has("ArrowRight")) player.x += speed * dt;
}Click to focus. WASD or arrow keys to move.
Now you move faster on the diagonal when → and ↑ are both held. In fact you're moving exactly √2 times faster, or about 1.41×. Why?
The answer has to do with right triangles. If you imagine that → moves you along the base of a right triangle and ↑ moves you up with the triangle's vertical leg, then pressing both at the same time moves you along the hypotenuse of that right triangle, which covers √2 times more distance than either leg over the same period of time.
One way to fix this is to divide out that extra √2 of velocity when traveling diagonally:
function fixedUpdate(dt: number) {
const speed = 80;
let vx = 0;
let vy = 0;
if (keysDown.has("ArrowUp")) vy -= speed * dt;
if (keysDown.has("ArrowDown")) vy += speed * dt;
if (keysDown.has("ArrowLeft")) vx -= speed * dt;
if (keysDown.has("ArrowRight")) vx += speed * dt;
if (vx !== 0 && vy !== 0) {
vx /= Math.SQRT2;
vy /= Math.SQRT2;
}
player.x += vx;
player.y += vy;
}Click to focus. WASD or arrow keys to move.
There's also a generalized solution to this problem.
If you want to support any direction, not just 45° diagonals, the general trick is to divide vx and vy by Math.hypot(vx, vy), their length. That results in a unit direction (a vector of length 1) pointed the same way. You can then multiply the speed * dt back in to get the correct final velocity.
function fixedUpdate(dt: number) {
const speed = 80;
let vx = 0;
let vy = 0;
if (keysDown.has("ArrowUp")) vy -= speed * dt;
if (keysDown.has("ArrowDown")) vy += speed * dt;
if (keysDown.has("ArrowLeft")) vx -= speed * dt;
if (keysDown.has("ArrowRight")) vx += speed * dt;
const hypot = Math.hypot(vx, vy) || 1;
player.x += (vx / hypot) * speed * dt;
player.y += (vy / hypot) * speed * dt;
}This is the approach we'll use to address a similar problem in Chapter 8 → on controllers where we look into handling movement using analog sticks.
Modulo
In languages like Ruby and Python, the % symbol is a modulo operator. It gives you the remainder when dividing two numbers, and wraps negative results back into a positive range. But in JavaScript and most other C-like languages, % is the remainder operator [MDN] and it does not wrap negative numbers.
items = ["Sword", "Bow", "Staff", "Shield"]
index % length = -1 // undefined
mod(index, length) = 3 // "Shield"
Things like clocks which are always increasing can be implemented using the % operator directly since the numbers are all positive. But games often also need a modulo operator that handles negative number wrapping, for things like world wrapping or item selection in hotbars. Since one isn't built into the language, we have to rely on our own:
function (: number, : number) {
return (( % ) + ) % ;
}Seeded random numbers
Before computers were effective at generating pseudorandom numbers, people relied on books filled with random digits [Wikipedia] in order to pull random numbers for work in fields that required actual randomness, such as cryptography, nuclear physics, or statistics.
With a book of random numbers, you could pick a page and line number, and although the digits you would find at that location would be completely random, you could return to that place any number of times in the future and get the exact same digits.
We get the same guarantee when we use a seeded random number generator. A seed is like the page and line numbers. It is a way to get back to the same set of random digits.
Why is that useful?
For procedurally generated games like Minecraft or games with deterministic randomness like Balatro, you can enter a specific seed to reproduce an exact (but still randomly-generated) game state. Sometimes this is just for fun—players can share interesting seeds with one another. But seeded randomness is also practical. You might have a bug that only occurs one in a thousand times, and in a truly random game that might be impossible to debug. If a tester shares the seed with the broken state, you can reliably test and reproduce the issue as many times as you need.
Roll the dice below to compare how the seeded random number generator repeats the same sequence after each Reset, while Math.random() does not.
Seeded random
Math.random()
Although the built-in Math.random() and crypto.getRandomValues() are also seeded random number generators, they're pre-seeded and we don't have an API to reset that seed. So we'll need to find our own seeded random algorithms that allow us to set the seed directly.
A popular PRNG is splitmix32, which is short enough to copy into your game's utility functions:
function splitmix32(seed: number) {
return function () {
seed |= 0;
seed = (seed + 0x9e3779b9) | 0;
let t = seed ^ (seed >>> 16);
t = Math.imul(t, 0x21f0aaad);
t = t ^ (t >>> 15);
t = Math.imul(t, 0x735a2d97);
return ((t = t ^ (t >>> 15)) >>> 0) / 4294967296;
};
}
const random = splitmix32(123);
// if you use seed 123 you'll also get these first 3 random numbers:
// random(); => 0.4575126694981009
// random(); => 0.21505506429821253
// random(); => 0.7675276368390769Is `splitmix32` any good? How does it compare to other PRNGs?
Utility functions
Once you've created your seeded random number generator, here's how you can use it to pick a random item from an array:
// an arbitrary seed #
const = 123;
// our seeded random number generator
const = ();
// selects a random element from an array
function <>(: [], = .) {
return [.(() * .)];
}
// pass in the seeded PRNG
([5, 2, 7, 9, 1], ); // => 7A handful of utilities that work with seeded random number generators:
// pick a random floating point number between min and max
function (: number, : number, = .) {
return + () * ( - );
}
// pick a random integer between min and max
function (: number, : number, = .) {
return .(() * ( - + 1)) + ;
}
// select a random element from an array
function <>(: [], = .) {
return [.(() * .)];
}
// shuffles an array in-place
function <>(: [], = .) {
for (let = . - 1; > 0; --) {
const = .(() * ( + 1));
[[], []] = [[], []];
}
return ;
}Sine waves
A sine wave gives us a number that oscillates between -1 and +1. We can pass it the current time to get a value in [-1, 1] that changes over time.
y = Math.sin(time);To change the speed of the oscillation, include a multiplier:
y = Math.sin(time * speed);Most of the time it's easier to work with values scaled between 0 and 1, instead of the -1 to +1 range we get with Math.sin.
y = (Math.sin(time) + 1) / 2;And while you could use sine waves for on/off blinking as well...
// rounded number (0 or 1)
y = Math.round((Math.sin(time) + 1) / 2);
// or a boolean value (true 50% of the time)
isOn = Math.sin(time) > 0;...modulo is a better fit for that task.
isOn = time % 1 < 0.5;Remember, your simulation might not need state.
So derive your game states from simple counters like time when you can.
Now, if you want the same kind of repeating up-and-down change, but with linear movement instead of smooth easing at the top and bottom, you can use a triangle wave like we did for the DVD bounce in Chapter 4:
const period = 2; // seconds for one full up/down cycle
const t = (time % period) / period; // 0..1
const y = t < 0.5 ? t * 2 : 2 - t * 2;Here's an assortment of practical applications of sine waves for games.
Oscillating movement (rotation)
const state = { time: 0 };
function update(dt: number) {
state.time += dt;
}
function draw(ctx: CanvasRenderingContext2D) {
const { width, height } = bounds;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#3d7ea6";
const size = 50;
const speed = 3;
const wobble = Math.sin(state.time * speed);
ctx.save();
ctx.translate(100, 100);
ctx.rotate(wobble);
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.restore();
}Oscillating movement (position)
const state = { time: 0 };
function update(dt: number) {
state.time += dt;
}
function draw(ctx: CanvasRenderingContext2D) {
const { width, height } = bounds;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#3d7ea6";
const size = 50;
const speed = 3;
const wobble = Math.sin(state.time * speed);
ctx.save();
ctx.translate(width / 2 + wobble * 100, height / 2);
ctx.fillRect(-size / 2, -size / 2, size, size);
ctx.restore();
}Oscillating opacity
const state = { time: 0 };
function update(dt: number) {
state.time += dt;
}
function draw(ctx: CanvasRenderingContext2D) {
const { width, height } = bounds;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#3d7ea6";
const size = 50;
const speed = 3;
// wobble is a value between -1 and +1
const wobble = Math.sin(state.time * speed);
// normalize it to go between 0 and 1:
const opacity = (wobble + 1) / 2;
ctx.globalAlpha = opacity;
ctx.fillRect(75, 75, size, size);
ctx.globalAlpha = 1;
}Blinking
You might be tempted to use a sine wave here because the blinking is periodic. But using modulo is simpler and more efficient.
const state = { time: 0 };
function update(dt: number) {
state.time += dt;
}
function draw(ctx: CanvasRenderingContext2D) {
const { width, height } = bounds;
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#3d7ea6";
const size = 50;
const isOn = state.time % 1 < 0.5;
if (isOn) {
ctx.fillRect(75, 75, size, size);
}
}π
When working with sine waves, you'll often see Math.PI in your code. For example, if you want oscillations to take one second:
y = Math.sin(time * Math.PI * 2);Where does that π come from?
Trigonometry. If you draw a dot moving around the unit circle, computing Math.sin of the angle from the origin to that dot gives us the dot's y position.
One full trip around the circle is 2π radians, so a sine wave also repeats every 2π radians. Since state.time is measured in seconds, passing it directly to Math.sin means the wave takes 2π seconds (about 6.28s) to complete one full cycle.
Let's say we want the result of Math.sin(...) > 0 to change every second. Since sine crosses zero every π radians, the angle has to advance by π each second.
Try increasing the scale until the positive interval lines up with 1s:
At scale = π, one second of time becomes π radians. So after 1s, the wave reaches π and crosses from positive to negative. After 2s, it reaches 2π and crosses back again.
Many people prefer using Tau τ over 2π because it makes more sense
to think in terms of complete circles than half-circles. For that same reason, changing units from
radians to turns can also
make this type of code easier to reason about. Unfortunately, JavaScript doesn't have pi-free trig
functions, so mapping your code to use turns over radians is a question of your mental model
rather than computational efficiency and precision.
Projectiles
If you were to draw an arrow from the origin (0, 0) to (x, y), how would you determine the angle of that arrow? From trigonometry we know tan(θ) = y / x, so maybe you could use the arctangent Math.atan(y / x)?
Math.atan fails when x is negative
Math.atan(y / x) is unaware of which quadrant we are in, so it doesn't work for every point.
Imagine testing the coordinates (1, 1) or (-1, -1). y / x for both of them will equal 1.
Math.atan2 solves this problem
To address this limitation of Math.atan(y / x), a similar built-in function Math.atan2(y, x) takes in two arguments (hence the 2 in its name). This reliably gets us an angle in radians from (0, 0) to any (x, y).
But how do we get an angle between any two points on our canvas, not just from (0, 0)? The trick is to translate so that one of the points is at the origin (0, 0), then make the second point's coordinates relative to that new origin. From there, atan2 will give you the angle from one point to the other in radians.
To pull out the x and y components from this angle, use the cosine and sine functions.
const = 100;
const = .() * ; // x velocity
const = .() * ; // y velocitySlightly simpler option if you don't need the angle
If all you need are the x and y velocities without first knowing the angle, you can avoid some trigonometry.
const = { : 10, : 20 };
const = { : 30, : 40 };
const = {
: . - .,
: . - .,
};
const = .(
., // targetX - originX
., // targetY - originY
);
const = 100;
const = (. / ) * ;
const = (. / ) * ;From here you can add gravity, explosions, or anything else to the projectiles.
Click to focus. WASD or arrow keys to move.
Utility functions
// how long is the line from the origin (0, 0) to the point at (x, y)
function (: number, : number) {
return .(, );
}
// how long is the line between (x1, y1) and (x2, y2)
function (: number, : number, : number, : number) {
return .( - , - );
}
// what is the angle from the origin (0, 0) to the point at (x, y)
function (: number, : number) {
return .(, ); // in radians
}
// what is the angle from (x1, y1) to (x2, y2)
function (: number, : number, : number, : number) {
return .( - , - ); // in radians
}
// if you need to convert the radians from `angle(x, y)` to degrees:
function (: number) {
return ( * 180) / .;
}
// or convert back from degrees to radians:
function (: number) {
return ( * .) / 180;
}
// pull out the x and y components of the unit vector pointing
// in the direction of the `angle` in radians
function (: number) {
return {
: .(),
: .(),
};
}Collisions & intersections
Detecting when in-game objects overlap is essential not just for things like didProjectileHitPlayer, but also to keep the player from falling through platforms or even just to make it possible to click on stuff.
This section is less a guide and more a collection of common code snippets for collision detection algorithms between various shapes.
The functions in the next sections will tell you when two objects collide, but you'll need to compare the current and past collision state if you want to act on the instant the collision occurred:
const = {
: false, // stale state from previous frame
: { : 20, : 50 },
: { : 30, : 40, : 50 },
};
function (: number) {
const = (., .);
const = && !.;
if () {
("whoosh");
();
}
. = ;
}Point in shape
Code snippets for point-in-shape checks
Segment collisions
Code snippets for segment collision checks
Ray collisions
Code snippets for ray intersection checks
Circle collisions
Code snippets for circle collision checks
Rect collisions (AABB)
When a rectangle is in the same orientation as the game canvas itself it's considered to be "axis-aligned". Collision detection is simpler with axis-aligned rectangles than rotated ones. A common abbreviation you'll see in many game engines for this is AABB (axis-aligned bounding box). The alternative, for rotated rectangles, is an "oriented bounding box", which we'll show in the next section.
Code snippets for rect collision checks
Rotated rect collisions (OBB)
Code snippets for rotated rect collision checks
Polygon collisions
Polygon collisions are expensive to compute and often not actually necessary. Instead, see if you can get away with simplifying your hit boxes to simpler shapes (rectangles, points, and circles).
Code snippets for polygon collision checks
Distance checks
Code snippets for distance checks
Dot product
The dot product of two vectors tells you how much they're pointed in the same direction.
- 1 means they're pointed exactly the same way
- 0 means they're perpendicular to one another
- -1 means they're pointed exactly opposite ways
type = { : number; : number };
function (: , : ) {
return . * . + . * .;
}If you need to determine whether an object is in front of vs behind something else, or within some enemy vision cone in a stealth game, for example, then the dot product is a useful tool.
You can also use dot to find which target best matches the player's aim, or in a sports game to determine which player to pass to. Just note that the dot product only tells you how close the angles line up—it doesn't incorporate the distance on its own.
The dot product is typically used on normalized vectors.
function (: number, : number): {
const = .(, );
if ( === 0) {
return { : 0, : 0 };
}
return { : / , : / };
}
const = (0.4, -0.9);
const = (0.2, -0.2);
const = (, ); // => 0.933...See also
- How to Turn a Few Numbers into Worlds [YouTube] — fractal Perlin noise explained, by The Taylor Series
- Making Randomness [YouTube] — pseudorandom number generators explained, by Jorge Rodriguez
- Math for Game Devs [YouTube] — four-part lecture series by Freya Holmér (+ Part 2, Part 3, Part 4)
- Math Visualizations — interactive visual math references by Freya Holmér
- Random [The Book of Shaders] — generating and using randomness for generative visuals, by Patricio Gonzalez Vivo & Jen Lowe
- Tools of the Trade [YouTube] — 2025 mathematics talk by GingerBill
- The Ultimate Guide to Cross Product and Dot Product — a post by Moonvane on the Roblox developer forums
- Trigonometry [YouTube] — from Sebastian Lague's Introduction to Game Development series
- What Kind of Math Should Game Developers Know? [YouTube] — overview of common math concepts for game development, by SimonDev