How to Implement Game Math in JavaScript
A custom math library is not about reinventing the wheel. It is about having exactly the tools you need in exactly the form you need them, with no framework dependency and no overhead. For 2D Canvas or PixiJS games, a lightweight Vec2 class with collision tests and interpolation covers nearly everything. For 3D games with Three.js or Babylon.js, the engine provides comprehensive math classes and you typically only add project-specific utilities. This guide focuses on the 2D case, which is where custom math code adds the most value.
Step 1: Build a Vec2 Class with Core Operations
The Vec2 class is the foundation of your library. Each instance stores x and y properties. The essential methods are add(v) (returns a new Vec2 with components summed), sub(v) (component-wise subtraction), scale(s) (multiply both components by a scalar), length() (magnitude using Pythagorean theorem), normalize() (returns a unit vector in the same direction), dot(v) (the dot product), and cross(v) (the 2D cross product, returning a scalar).
Add convenience methods: distanceTo(v) computes the distance between two position vectors (magnitude of the difference). angleTo(v) returns the angle from this vector to v using atan2. rotate(angle) applies the 2D rotation formulas. clone() creates a copy. set(x, y) updates components without creating a new object.
A critical design decision is mutability. Immutable methods (returning new Vec2 instances) are cleaner but generate garbage that the garbage collector must reclaim, causing frame hitches. Mutable methods (modifying the vector in place) are faster for hot loops. A practical approach is to offer both: add(v) returns a new vector, addSelf(v) modifies in place. For your game loop's core update, use the mutable variants. For setup and configuration code, use immutable variants for clarity.
Static factory methods like Vec2.fromAngle(angle, length) create a vector from polar coordinates: x = length * cos(angle), y = length * sin(angle). This is frequently used for spawning projectiles in a given direction, calculating positions around a circle, and converting angular velocity to a velocity vector.
Step 2: Add Rotation and Trigonometry Utilities
Create standalone functions outside the Vec2 class for general-purpose trigonometry. toRad(degrees) converts degrees to radians by multiplying by Math.PI / 180. toDeg(radians) converts radians to degrees by multiplying by 180 / Math.PI. These two functions appear in nearly every game project.
rotatePoint(x, y, cx, cy, angle) rotates point (x, y) around center (cx, cy) by the given angle in radians. Translate to origin, apply the rotation formulas (newX = dx * cos - dy * sin, newY = dx * sin + dy * cos), translate back. Compute cos and sin once and reuse them for both formulas.
angleBetween(x1, y1, x2, y2) returns the angle from point 1 to point 2 using Math.atan2(y2 - y1, x2 - x1). This is the most common trigonometric operation in games: aiming, facing, and turning all use it.
normalizeAngle(angle) wraps an angle to the range -PI to PI (or 0 to 2*PI, depending on your convention). Without normalization, angles accumulate beyond 2*PI or below -2*PI over time, which can cause comparison bugs and visual artifacts in rotation interpolation. The formula is angle - Math.floor(angle / (2 * Math.PI)) * (2 * Math.PI) for the 0 to 2*PI range.
Step 3: Implement Interpolation Functions
lerp(a, b, t) is one line: return a + (b - a) * t;. Despite its simplicity, pulling it into a named function makes your game code dramatically more readable and eliminates the risk of typos in the formula.
inverseLerp(a, b, value) returns the t parameter: return (value - a) / (b - a);. Guard against division by zero when a equals b by returning 0 or 0.5 as a safe default.
remap(value, fromMin, fromMax, toMin, toMax) chains inverse lerp and lerp: return lerp(toMin, toMax, inverseLerp(fromMin, fromMax, value));. Add an optional clamp parameter that clamps the t value to 0-1 to prevent output values outside the destination range.
smoothstep(t) applies the cubic S-curve: return t * t * (3 - 2 * t);. Clamp t to 0-1 first. For smootherstep: return t * t * t * (t * (t * 6 - 15) + 10);.
dampedLerp(current, target, speed, dt) provides frame-rate-independent smoothing: return lerp(current, target, 1 - Math.pow(1 - speed, dt * 60));. The speed parameter (0 to 1) controls how quickly the value converges. The dt parameter is delta time in seconds. This function produces identical behavior at 30, 60, or 144 FPS.
Step 4: Add Collision Detection Tests
aabbOverlap(a, b) tests two axis-aligned rectangles, each defined as {x, y, w, h} where x and y are the top-left corner. The test is: return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;. Four comparisons, no math functions.
circleOverlap(x1, y1, r1, x2, y2, r2) tests two circles using squared distance: var dx = x2 - x1, dy = y2 - y1, dr = r1 + r2; return dx*dx + dy*dy < dr*dr;. No square root needed.
pointInCircle(px, py, cx, cy, r) is the same pattern with r2 = 0: var dx = px - cx, dy = py - cy; return dx*dx + dy*dy < r*r;. Use this for click detection on circular objects.
pointInRect(px, py, rx, ry, rw, rh) is four comparisons: return px >= rx && px <= rx + rw && py >= ry && py <= ry + rh;. Use this for click detection on rectangular UI elements and tiles.
circleRectOverlap(cx, cy, cr, rx, ry, rw, rh) finds the closest point on the rectangle to the circle center by clamping, then tests if that point is within the radius: var closestX = Math.max(rx, Math.min(cx, rx + rw)); var closestY = Math.max(ry, Math.min(cy, ry + rh)); var dx = cx - closestX, dy = cy - closestY; return dx*dx + dy*dy < cr*cr;.
Step 5: Add Bezier Curve Evaluation
quadBezier(p0, p1, p2, t) evaluates a quadratic bezier at parameter t. Using the polynomial form: var mt = 1 - t; return mt*mt*p0 + 2*mt*t*p1 + t*t*p2;. Call this separately for x and y components. Returns a single number, so use it twice to get a 2D point.
cubicBezier(p0, p1, p2, p3, t) evaluates a cubic bezier: var mt = 1 - t; return mt*mt*mt*p0 + 3*mt*mt*t*p1 + 3*mt*t*t*p2 + t*t*t*p3;. Same pattern, four control points instead of three.
catmullRom(p0, p1, p2, p3, t) evaluates a Catmull-Rom spline segment between p1 and p2: var t2 = t*t, t3 = t2*t; return 0.5 * ((2*p1) + (-p0+p2)*t + (2*p0-5*p1+4*p2-p3)*t2 + (-p0+3*p1-3*p2+p3)*t3);. This produces a smooth curve that passes through p1 at t=0 and p2 at t=1.
For evaluating curves on 2D points, create wrapper functions that call the scalar version for each component: function cubicBezier2D(p0, p1, p2, p3, t) { return { x: cubicBezier(p0.x, p1.x, p2.x, p3.x, t), y: cubicBezier(p0.y, p1.y, p2.y, p3.y, t) }; }. In performance-critical code, pass the x and y results into an existing object to avoid allocating a new one each frame.
Step 6: Optimize for Browser Performance
Minimize object allocation in your game loop. Creating new Vec2 instances every frame generates garbage that causes periodic GC pauses (visible as frame hitches). Use mutable operations (addSelf, subSelf) on pre-allocated vectors. Keep a few scratch vectors for temporary calculations and reuse them across frames.
Cache trigonometric values. If you compute sin(angle) and cos(angle) for a rotation, store both in local variables and reuse them. Trig functions are roughly 10x slower than addition and multiplication. If the angle has not changed since last frame, skip the trig call entirely and reuse the cached values.
Use squared distances instead of actual distances whenever you only need to compare magnitudes. Comparing dx*dx + dy*dy < r*r avoids the Math.sqrt call entirely, which matters when checking hundreds of collision pairs per frame.
Avoid Math.hypot. While Math.hypot(dx, dy) is more readable than Math.sqrt(dx*dx + dy*dy), it is significantly slower in most JavaScript engines because it handles edge cases (overflow, underflow, NaN) that game code does not need. Use the manual sqrt version in performance-critical paths.
Profile before optimizing. Use Chrome DevTools Performance panel or Firefox Profiler to identify actual bottlenecks. Most web games are limited by rendering (draw calls, fill rate) rather than math. Optimize math only when profiling shows it is the bottleneck, and focus on the specific functions that appear in the hot path.
A complete 2D game math library fits in under 200 lines of JavaScript: a Vec2 class, trig utilities, lerp family, collision tests, and curve evaluation. Use mutable operations in game loops to avoid garbage collection pauses, cache trig values, and compare squared distances to skip square roots.