Tutorial · Orbital Mechanics · Canvas 2D · JavaScript
📅 July 2026 ⏱ ≈ 20 min 🎯 Beginner

Orbit Simulation in 50 Lines of JavaScript

A physically correct, closed elliptical orbit needs surprisingly little code: Newton's inverse-square gravity, a stable velocity-Verlet integrator, and a fading trail. This tutorial builds the whole thing in under 50 lines of plain Canvas 2D — no Three.js, no physics library.

1. The Setup: One Fixed Sun, One Free Planet

The full two-body problem lets both bodies move around their common centre of mass. But if one mass (the Sun) is thousands of times heavier than the other (a planet or spacecraft), it barely moves at all — so we can pin it at the origin and only integrate the lighter body's position r and velocity v. This is the same simplification used by the Kepler orbital-mechanics article, but here we integrate numerically instead of solving Kepler's equation analytically — the approach that generalises to orbits perturbed by other bodies.

const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
canvas.width = canvas.height = 500;

const MU = 4000;               // gravitational parameter G*M, tuned for pixel units
let pos = { x: 180, y: 0 };  // planet starts at perihelion
let vel = { x: 0, y: 5.6 };  // initial tangential speed

2. Newtonian Gravity as Acceleration

Newton's law of gravitation gives an acceleration that always points from the planet toward the Sun, scaled by the inverse square of the distance:

a = −μ · r / |r|³

As a function, it takes the current position and returns the acceleration vector:

function accel(p) {
  const r2 = p.x * p.x + p.y * p.y;
  const r = Math.sqrt(r2);
  const f = -MU / (r2 * r);   // -μ / r³, applied to each component below
  return { x: f * p.x, y: f * p.y };
}
Same law, two units systems: The two-body problem article uses AU and years with μ = 4π²; here we use pixels and frames with an arbitrary μ = 4000 tuned so the orbit fits nicely on a 500×500 canvas. The physics is identical — only the unit scale changes.

3. Why Euler Integration Fails

The obvious first attempt is forward-Euler: update velocity from acceleration, then position from velocity, each frame:

// DON'T do this for orbits:
const a = accel(pos);
vel.x += a.x * dt; vel.y += a.y * dt;
pos.x += vel.x * dt; pos.y += vel.y * dt;

This looks correct and even runs — but it silently adds energy to the system every single step, because velocity is updated before being used to move the position, using acceleration evaluated at the old position. The orbit doesn't stay elliptical: it spirals slowly outward, frame after frame, until the planet escapes entirely.

The tell: if your orbit's aphelion distance keeps growing run after run with a fixed time step, you're almost certainly looking at Euler drift, not a bug in your gravity formula.

4. Velocity-Verlet: a Stable Integrator

Velocity-Verlet fixes this by splitting the velocity update into two half-steps around the position update. It's still only three extra lines, but it's symplectic — it conserves a quantity very close to total energy over arbitrarily many steps, so a closed orbit stays closed instead of spiralling:

function step(dt) {
  const a0 = accel(pos);
  pos.x += vel.x * dt + 0.5 * a0.x * dt * dt;
  pos.y += vel.y * dt + 0.5 * a0.y * dt * dt;
  const a1 = accel(pos);           // re-evaluate at the NEW position
  vel.x += 0.5 * (a0.x + a1.x) * dt;
  vel.y += 0.5 * (a0.y + a1.y) * dt;
}

The key difference from Euler: acceleration is sampled twice per step — once at the old position (a0) and once at the new position (a1) — and the velocity update averages the two. This single change is what the industry-standard leapfrog N-body integrator is built on.

5. Drawing the Trail

To see the ellipse trace itself out, keep the last few hundred positions in an array and draw them with opacity fading toward the oldest point:

const trail = [];
function draw() {
  ctx.fillStyle = '#0a0e1a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  ctx.translate(canvas.width / 2, canvas.height / 2);

  trail.push({ x: pos.x, y: pos.y });
  if (trail.length > 300) trail.shift();
  trail.forEach((t, i) => {
    ctx.fillStyle = `rgba(129,140,248,${i / trail.length})`;
    ctx.fillRect(t.x, t.y, 2, 2);
  });

  ctx.fillStyle = '#f5c842';               // the Sun
  ctx.beginPath(); ctx.arc(0, 0, 10, 0, 7); ctx.fill();
  ctx.fillStyle = '#4fa3e0';               // the planet
  ctx.beginPath(); ctx.arc(pos.x, pos.y, 5, 0, 7); ctx.fill();
  ctx.restore();
}

6. Full Source (50 Lines)

Paste this into an HTML file with a <canvas id="c"></canvas> — it runs standalone, no dependencies:

const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
canvas.width = canvas.height = 500;

const MU = 4000;
let pos = { x: 180, y: 0 };
let vel = { x: 0, y: 5.6 };
const trail = [];

function accel(p) {
  const r2 = p.x * p.x + p.y * p.y;
  const r = Math.sqrt(r2);
  const f = -MU / (r2 * r);
  return { x: f * p.x, y: f * p.y };
}

function step(dt) {
  const a0 = accel(pos);
  pos.x += vel.x * dt + 0.5 * a0.x * dt * dt;
  pos.y += vel.y * dt + 0.5 * a0.y * dt * dt;
  const a1 = accel(pos);
  vel.x += 0.5 * (a0.x + a1.x) * dt;
  vel.y += 0.5 * (a0.y + a1.y) * dt;
}

function draw() {
  ctx.fillStyle = '#0a0e1a';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.save();
  ctx.translate(canvas.width / 2, canvas.height / 2);

  trail.push({ x: pos.x, y: pos.y });
  if (trail.length > 300) trail.shift();
  trail.forEach((t, i) => {
    ctx.fillStyle = `rgba(129,140,248,${i / trail.length})`;
    ctx.fillRect(t.x, t.y, 2, 2);
  });
  ctx.fillStyle = '#f5c842';
  ctx.beginPath(); ctx.arc(0, 0, 10, 0, 7); ctx.fill();
  ctx.fillStyle = '#4fa3e0';
  ctx.beginPath(); ctx.arc(pos.x, pos.y, 5, 0, 7); ctx.fill();
  ctx.restore();
}

function loop() {
  step(0.06);
  draw();
  requestAnimationFrame(loop);
}
loop();

↑ The actual 50-line simulation above, running live: a stable ellipse traced by velocity-Verlet integration.

Next step: Add a second free-moving body instead of a fixed Sun and you get the full two-body problem — or add more bodies entirely and you need the N-body / Barnes-Hut approach.

Frequently Asked Questions

What will I learn in this tutorial?

Build a working two-body orbit simulation in under 50 lines of plain Canvas 2D and JavaScript: Newtonian gravity, velocity-Verlet integration, and a trailing ellipse — no libraries, no build step.

What topics are covered in this tutorial?

This tutorial covers: The Setup: One Fixed Sun, One Free Planet, Newtonian Gravity as Acceleration, Why Euler Integration Fails, Velocity-Verlet: a Stable Integrator, Drawing the Trail, Full Source (50 Lines).

How long does this tutorial take?

This tutorial takes approximately 20 minutes to complete.

What prerequisites do I need before starting?

This is a Beginner-level tutorial — no special preparation beyond basic JavaScript is assumed.