N-Body Gravity Simulation at 60fps in the Browser
I failed physics in high school
Not literally — I passed — but gravity was the chapter I slept through. Years later I wanted to see planets orbit each other in real time, so I sat down and wrote Newton's law from scratch. Turns out the formula is surprisingly simple. The hard part is making it stable.
The gravity equation
Every pair of bodies attracts each other with a force proportional to their masses and inversely proportional to the square of the distance between them:
F = G * m1 * m2 / r²In code, for each pair of bodies, I compute the force vector and accumulate accelerations:
for (let i = 0; i < bodies.length; i++) {
for (let j = i + 1; j < bodies.length; j++) {
const dx = bodies[j].x - bodies[i].x;
const dy = bodies[j].y - bodies[i].y;
const distSq = dx * dx + dy * dy;
const dist = Math.sqrt(distSq);
if (dist < bodies[i].radius + bodies[j].radius) {
handleCollision(bodies[i], bodies[j]);
continue;
}
const force = G * bodies[i].mass * bodies[j].mass / distSq;
const fx = force * dx / dist;
const fy = force * dy / dist;
bodies[i].ax += fx / bodies[i].mass;
bodies[i].ay += fy / bodies[i].mass;
bodies[j].ax -= fx / bodies[j].mass;
bodies[j].ay -= fy / bodies[j].mass;
}
}That nested loop is O(n²). With 10 bodies, that's 45 pairs. With 50 bodies, it's 1,225 pairs. With 200 bodies — 19,900 pairs. Each pair needs a square root (for distance), a division (for force), and two more divisions (for acceleration). On a modern browser, this stays under 1ms for up to about 300 bodies. Beyond that, you'd want a Barnes-Hut tree, but for a sandbox, 300 is plenty.
The Verlet integration trick
My first implementation used Euler integration: velocity += acceleration * dt, then position += velocity * dt. It worked for about 5 seconds before orbits spiraled outward and everything flew apart. Euler integration adds energy to the system every frame. For gravity simulations, that's fatal.
Position Verlet integration is dramatically better:
// Position Verlet: x_new = 2*x - x_old + a*dt²
const newX = 2 * body.x - body.prevX + body.ax * dt * dt;
const newY = 2 * body.y - body.prevY + body.ay * dt * dt;
body.prevX = body.x;
body.prevY = body.y;
body.x = newX;
body.y = newY;Instead of tracking velocity explicitly, Verlet derives it from the difference between current and previous positions. This is symplectic — it approximately conserves energy over long periods. Orbits that should be stable stay stable. I tested it with a two-body system where I knew the analytical solution, and the orbit period was accurate to within 0.3% after 1000 revolutions. With Euler, it had diverged to infinity after about 40.
The timestep problem
Too large a timestep and fast-moving bodies skip past each other. Too small and you burn CPU on tiny increments. I landed on a fixed timestep of 1/120 second (half of the display refresh rate), with the render loop decoupled:
const FIXED_DT = 1 / 120;
let accumulator = 0;
function update(frameDelta) {
accumulator += frameDelta;
while (accumulator >= FIXED_DT) {
physicsTick(FIXED_DT);
accumulator -= FIXED_DT;
}
render();
}This means physics always runs at 120 ticks per second regardless of frame rate. If the display is 60fps, each frame runs two physics ticks. If it drops to 30fps, four ticks. The simulation stays deterministic.
Six body types
The sandbox has six body types with different masses and visual treatments:
- Asteroid — tiny, low mass, grey
- Moon — small, moderate mass, silver
- Planet — medium, colorful (randomized hue)
- Gas giant — large, high mass, banded texture
- Star — massive, glowing with a pulsing corona effect
- Black hole — extreme mass, rendered as a dark circle with a warped glow ring
Each type is just a mass range and a render function. The physics doesn't care what kind of body something is — mass is mass.
Three collision models
Players can switch between collision behaviors:
1. Merge — colliding bodies combine into one. Masses add. The new body takes the center-of-mass position and the momentum-weighted velocity. This is the most realistic (and the default). 2. Bounce — elastic collision. Bodies bounce off each other conserving momentum and energy. Fun for creating billiard-like systems but unrealistic for space. 3. Pass-through — bodies ignore collisions entirely. Only gravitational interaction. Useful for studying pure orbital dynamics without disruption.
Merge is the most interesting because it naturally creates hierarchical systems. Scatter 50 asteroids and watch them clump into a few large bodies with smaller ones orbiting. It's emergent behavior — you don't program "form a solar system," it just happens from the physics.
Orbital trails
Each body leaves a fading trail showing its recent path. This is rendered on a separate canvas layer:
body.trail.push({ x: body.x, y: body.y });
if (body.trail.length > MAX_TRAIL) body.trail.shift();
ctx.beginPath();
for (let i = 0; i < body.trail.length; i++) {
const alpha = i / body.trail.length;
ctx.strokeStyle = `hsla(${body.hue}, 70%, 60%, ${alpha * 0.6})`;
const p = body.trail[i];
if (i === 0) ctx.moveTo(p.x, p.y);
else ctx.lineTo(p.x, p.y);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(p.x, p.y);
}Each trail segment has its own alpha, fading from transparent (oldest) to opaque (newest). The trail color matches the body's hue. This is expensive — each trail point is a separate stroke() call — but with 200-point trails on 30 bodies, it stays within budget. The visual payoff is enormous. Without trails, orbits are invisible and the simulation looks chaotic. With trails, you can see the beautiful ellipses and figure-eights that emerge.
Live diagnostics
The UI shows total kinetic energy, total potential energy, and center-of-mass position, all updated every frame. This is partly for educational value (watch energy conservation in action) and partly for debugging. If total energy starts climbing, something is wrong with the integration. During development, this readout caught several bugs — like the time I accidentally applied gravity twice per tick and energy doubled every second.
Canvas 2D was the right choice
I considered WebGL for the rendering but Canvas 2D turned out to be fine. The bodies are circles. The trails are lines. The glow effects are radial gradients. None of that needs shaders. Canvas 2D also has the advantage of being dead simple to debug — you can inspect every draw call in the browser's timeline profiler. For a simulation where the physics is the interesting part and the visuals are secondary, keeping the rendering layer simple was the right call.
The whole thing runs at a locked 60fps with 100+ bodies in Chrome, Firefox, and Safari. No build step, no framework, no dependencies. Just an HTML file, a JS file, and Newton's law.
Written by Oliver
Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.