MathematicsTrigonometrycore4 min read
Trigonometry for Games
angles in, directions out, and back again with atan2
Sine and cosine turn an angle into a direction on the unit circle; atan2 turns a direction back into an angle for aiming and turning.
The green diamond is a target. The thin white arrow is the unit direction θ = atan2(dy, dx) towards it, ending at the white dot on the grey unit circle, and the white dashed line continues to the target. The thick yellow bar along the axis is cos θ and the thick grey upright bar is sin θ; the white arc is θ itself. Turn the target off and drag the angle past ±180° to see it wrap.
What it is
Put a point on a circle of radius 1 at angle θ, measured counter-clockwise from +x. Its x coordinate is cos θ and its y coordinate is sin θ. That is the whole idea: cos and sin turn an angle into a unit direction. Multiply by a radius to get a point on any circle, add a centre to move it, and animate θ to orbit. Because the point is on the unit circle, cos²θ + sin²θ = 1 always.
Going the other way, from a direction to an angle, use `Math.atan2(y, x)`. Plain atan(y / x) loses the quadrant (both (1, 1) and (−1, −1) give 45°) and divides by zero straight up; atan2 looks at both signs and returns the full range (−π, π]. Note the argument order: y first. Aiming a turret, rotating a sprite to face its velocity and computing a heading for vectors all go through atan2.
Every JavaScript and Three.js angle is in radians: a full turn is 2π ≈ 6.283, so 1 rad ≈ 57.3°. Keep degrees for UI and config, convert at the edges with THREE.MathUtils.degToRad, and do the maths in radians.
Angles wrap: 350° and −10° are the same heading. Before subtracting two headings, wrap the difference into (−π, π] so a turret at 170° turning to −170° goes 20° the short way instead of 340° the long way. For 3D orientation, angles alone hit their limits; see quaternions.
- sin
- cos
- atan2
- unit circle
- radians
- angle wrapping
- aiming
Key formulas
- Unit circle
- Aim angle
Δ = target − origin. Arguments are (y, x), not (x, y).
- Degrees and radians
- Wrap into (−π, π]
Rules of thumb
- Orbit or circle motion:
x = cx + r·cos(ω·t),y = cy + r·sin(ω·t); ω = 2π / period, so a 4 s orbit uses ω ≈ 1.571 rad/s. - Turn rate limits: 180–360°/s feels snappy for turrets, 90–120°/s feels heavy. Multiply by dt and clamp the step to the wrapped difference.
- Bob and pulse:
offset = amplitude · sin(2π · frequency · t); 0.05–0.15 units at 1–2 Hz reads as idle breathing. - If you only need to know whether something is in front or to the left, a dot or cross product is cheaper and avoids wrapping entirely.
- Three.js cameras and models face −Z: a yaw from atan2 on the XZ plane is usually
Math.atan2(dx, dz)plus π or with a negated axis. Test one known direction before trusting it.
Implementation
const TAU = Math.PI * 2const wrap = (a: number) => a - TAU * Math.floor((a + Math.PI) / TAU)
function aimTurret(turret: THREE.Object3D, target: THREE.Vector3, dt: number) { const dx = target.x - turret.position.x const dz = target.z - turret.position.z const desired = Math.atan2(dx, dz) // yaw about +Y; 0 faces +Z const diff = wrap(desired - turret.rotation.y) const maxStep = THREE.MathUtils.degToRad(240) * dt turret.rotation.y = wrap( turret.rotation.y + THREE.MathUtils.clamp(diff, -maxStep, maxStep) ) return Math.abs(diff) < THREE.MathUtils.degToRad(5) // on target: fire}for (let i = 0; i < n; i++) { const theta = (i / n) * Math.PI * 2 enemies[i].position.set( center.x + radius * Math.cos(theta), 0, center.z + radius * Math.sin(theta) )}Common mistakes
- Passing degrees to
Math.sin/Math.cos.Math.sin(90)is 0.894, not 1. - Writing
Math.atan2(x, y)by habit. The order is (y, x); swapping it mirrors the angle across the diagonal. - Using
atan(dy / dx): it loses the quadrant and blows up when dx is 0. - Lerping raw angles across the ±180° seam, so the object spins the long way round. Wrap the difference first.
- Letting an accumulated angle grow forever; after hours of play precision drops. Wrap it each frame.
Prompt
Write `src/lib/angles.ts` in TypeScript with: `degToRad`, `radToDeg`, `wrapAngle(a)` mapping any radian value into (-π, π] without loops, `angleDelta(from, to)` = shortest signed turn, `dirFromAngle(theta, out)` returning (cos θ, sin θ), `aimAngle(from, to)` using Math.atan2(dy, dx), and `rotateTowards(current, target, maxStep)` that turns by at most maxStep along the short way and lands exactly on the target. Then use them in a Three.js top-down turret on the XZ plane: each frame compute the yaw to the mouse point with atan2 (mind the axis order and sign for -Z forward), rotate towards it at 180°/s using dt, and only fire when |angleDelta| < 5°. Write Vitest tests: atan2 returns the right angle in all four quadrants and on the axes; wrapAngle(3π) = π and wrapAngle(-190°) = 170°; rotateTowards crosses the ±180° seam the short way.In learning paths
Related
Further reading
Updated 2026-11-01
