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:
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 };
}
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.
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.
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.