← DEVLOG
Physics Simulation2025.11.208 min read

Slingshot — Turning Gravity Assist into a Game

A probe that steals speed from planetary gravity — building orbital mechanics simulation into a level-based puzzle game

canvasphysicsorbital-mechanicsgravity-assist

Starting Point — What Voyager 1 Actually Did

Voyager 1 launched in 1977 and is still flying beyond the solar system today. Rocket fuel alone did not get it there. The gravitational slingshot maneuvers around Jupiter and Saturn gave it a speed no rocket could have achieved on its own.

Gravity assist works because a spacecraft flying past a planet exchanges momentum with it. The planet slows down by an imperceptible amount; the spacecraft gains an enormous velocity change. The question was: could this be turned into a game that lets you feel what Voyager's trajectory designers once calculated by hand?


Physics Engine: N-Body Gravity Every Frame

The physical core is a gravity simulation. Newton's law is applied between the probe and each planet on every frame.

for (const body of planets) {
  const dx = body.x - probe.x;
  const dy = body.y - probe.y;
  const distSq = Math.max(dx * dx + dy * dy, MIN_DIST_SQ);
  const dist = Math.sqrt(distSq);
  const force = (G * body.mass) / distSq;
  probe.vx += (dx / dist) * force;
  probe.vy += (dy / dist) * force;
}
probe.x += probe.vx;
probe.y += probe.vy;

Planets are treated as fixed bodies. Gravitational interactions between planets are ignored; only forces acting on the probe are computed. This keeps level design predictable — the intended slingshot path reproduces identically on every attempt.


Target Orbit and the Circular Orbit Condition

The goal of each level is to insert the probe into a circular orbit around a designated target planet. Determining success requires comparing the probe's current speed against the circular orbit velocity at its current distance.

// Distance from the target planet
const r = Math.hypot(probe.x - target.x, probe.y - target.y);
// Circular orbit condition: v² = G * M / r
const vCircular = Math.sqrt((G * target.adaptedMass) / r);
// Probe speed relative to the planet
const vProbe = Math.hypot(probe.vx - target.vx, probe.vy - target.vy);
// Success if velocity error is within tolerance
if (Math.abs(vProbe - vCircular) / vCircular < ORBIT_TOLERANCE) {
  st.phase = 'success';
}

As levels progress, ORBIT_TOLERANCE tightens, demanding increasingly precise shots.


Level Design: Planet Placement and Mass Scaling

Level 1 has one planet; level 5 has five. More planets create more complex gravitational interference, naturally increasing difficulty.

One design problem arose early: as the canvas size changes, distances between planets change, which alters orbital conditions even with identical masses. A getCachedOrSolve function corrects planet masses to match the current canvas scale.

// Scale the target orbit radius to the current canvas size
const adaptedTargetR = targetR * (canvasW / BASE_W);
// Circular velocity at that adapted radius
const v = Math.sqrt((G * adaptedMass) / adaptedTargetR);
// Planet mass also scales with level progression via k3 factor
adaptedMass = baseMass * Math.pow(k3, level - 1);

This correction ensures the same difficulty curve on a narrow mobile portrait screen as on a widescreen desktop.


Launch Interface: Mouse and Touch Aiming

The probe is launched by dragging — mouse on desktop, touch on mobile. The drag vector from start to release is inverted to determine launch direction and speed, like drawing a bow: pull further for more power.

const onPointerUp = (e: PointerEvent) => {
  if (!dragStart) return;
  const dx = dragStart.x - e.clientX; // inverted
  const dy = dragStart.y - e.clientY;
  const power = Math.hypot(dx, dy);
  probe.vx = (dx / power) * Math.min(power, MAX_POWER) * POWER_SCALE;
  probe.vy = (dy / power) * Math.min(power, MAX_POWER) * POWER_SCALE;
  st.phase = 'flying';
};

A dotted drag line shows current aim direction, and a semi-transparent predicted trajectory curve previews the initial arc. This preview makes aiming intuitive even without any physics knowledge.


Trail Rendering

Rendering the probe's path makes the gravity assist effect visible. As the probe swings past a planet, the trail visibly bends — a parabolic arc curving into a hyperbolic trajectory — and the speed change shows in how the dots are spaced.

// Push current position onto the trail each frame
trail.push({ x: probe.x, y: probe.y });
if (trail.length > TRAIL_MAX) trail.shift();

// Render: alpha fades with age
trail.forEach((pt, i) => {
  const alpha = (i / trail.length) * 0.6;
  ctx.fillStyle = `rgba(103, 232, 249, ${alpha})`;
  ctx.beginPath();
  ctx.arc(pt.x, pt.y, 1.5, 0, Math.PI * 2);
  ctx.fill();
});

Storing Records: slingshot:best

The minimum number of attempts needed to complete each level is saved under the slingshot:best key. The record is updated whenever the player finishes a level with fewer attempts than before.

const prev = storageGet<{ level: number; attempts: number } | null>('slingshot:best', null);
if (!prev || level > prev.level || (level === prev.level && attempts < prev.attempts)) {
  storageSet('slingshot:best', { level, attempts });
}

storageGet/storageSet are project-wide utilities that replace direct localStorage access and handle legacy key migration automatically.


Canvas Performance

4K Resolution Cap

On high-DPI displays, canvas buffer size can grow to four or more times the expected pixel count, making every gravity calculation and trail draw proportionally more expensive. The buffer area is capped at 1920 × 1080:

const MAX_AREA = 1920 * 1080;
const area = w * h;
const scale = area > MAX_AREA ? Math.sqrt(MAX_AREA / area) : 1;
canvas.width = Math.round(w * scale);
canvas.height = Math.round(h * scale);

This is particularly important for Slingshot because the N-body gravity loop runs per-frame while simultaneously rendering planet glow effects and the probe trail — all of which scale with buffer size.

Resize Debounce

Window resize events can fire dozens of times per second during a drag resize. Each event was recalculating canvas dimensions, recomputing planet mass scaling, and resetting the render context — causing visible stutter during the resize gesture.

A 150ms debounce on the resize handler eliminates this. The canvas update only fires once the user has stopped resizing, and the game loop continues uninterrupted during the resize gesture.


Closing Thoughts

The moment the first success condition fired and I watched the probe settle into orbit, I had a small glimpse of what the Voyager trajectory engineers must have felt. It is, at its core, a physics puzzle where gravity is your ally rather than your enemy.

Building games around real physical laws has taught me that the hardest question is not "how realistic should this be?" but "how do I make this feel intuitive and fun?" If this game conveys even a little of the elegance behind what Voyager actually did, it will have been worth making.

Content related to this post

Try it yourself