Skip to content
SciStack
Minigame Python Beginner 25 min

Gravity assist past Jupiter: faster in the Sun's frame, not in Jupiter's

Afterwards you can explain a gravity assist as a turn of the velocity in the planet's frame, and compute the Sun-frame speed it adds and the planet's recoil.

Field
Engineering, Physics
Prerequisites
none beyond Python basics
Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
Download notebook Save Mark as done

py-gravity-assist.ipynb, executed with the versions above. The download needs a free account

Run it yourself. In a terminal, this installs exactly the versions above:

pip install numpy==2.4.3 scipy==1.18.1 matplotlib==3.11.2 jupyterlab

Play: aim Voyager 2 past Jupiter

On 9 July 1979 Voyager 2 passed Jupiter at 10.1 Jupiter radii (\(R_J\)) from its center and came away with twice the speed relative to the Sun that it brought. That is a gravity assist, and the game puts you at the controls of the same approach. Jupiter moves along its orbit at 13.06 km/s, with the Sun far beyond the top edge of the Sun's view; the game moves Jupiter on its circle and pulls the probe toward both Jupiter and the Sun. Drag in Jupiter's view, or use Aim − and +, to set where the probe's straight incoming line would pass Jupiter, in front of it or behind it, then press Launch. Warp speeds up time, and Reset starts over. Jupiter is to scale in its own view and drawn larger in the Sun's. Watch the two speeds. At Voyager's aim, where the game starts, the probe comes in at 10.21 km/s relative to the Sun and leaves at 20.37 km/s, while relative to Jupiter it moves at 8.34 km/s on the way in and 8.33 km/s on the way out.

Download the game

Show code
// game.js: aim a probe past Jupiter and watch its speed in two frames. Units: km, km/s, s.
// The Sun sits fixed far above the views; Jupiter moves along +x on a circle; the probe
// feels both. The notebook writes the constants and the gain curve into the #data element.
"use strict";

const DATA = JSON.parse(document.getElementById("data").textContent);
const {gm_sun: GM_SUN, gm_j: GM_J, r_j: R_J, a_j: A_J, v_inf: V_INF} = DATA;
const V_J = Math.sqrt(GM_SUN / A_J), OMEGA = V_J / A_J;
const R_START = DATA.r_start * R_J;                       // the flight starts and ends this far out
const DAY = 86400;
const WARPS = [1 / 24, 3 / 24, 0.5, 1, 2, 4];             // days of flight per second of play
const PHI = (-DATA.approach_deg * Math.PI) / 180;         // direction of v∞,in; 0 is Jupiter's motion
const U_IN = [Math.cos(PHI), Math.sin(PHI)];
const N_AIM = [Math.cos(PHI - Math.PI / 2), Math.sin(PHI - Math.PI / 2)];   // the side "behind"

// ---- small vector helpers
const add = (a, b) => [a[0] + b[0], a[1] + b[1]];
const sub = (a, b) => [a[0] - b[0], a[1] - b[1]];
const scale = (a, k) => [a[0] * k, a[1] * k];
const len = (a) => Math.hypot(a[0], a[1]);

// ---- the state: everything that changes, in one object
const DEFAULTS = {aim: DATA.aim_voyager, warp: 3, t: 0, r: [0, 0], v: [0, 0], running: false, done: false,
  hit: false, rmin: Infinity, speeds: null, trail: [], dots: []};
const state = {...DEFAULTS, dots: []};

function jupiter(t) {                                     // position and velocity on the circle
  const th = -Math.PI / 2 + OMEGA * t;                    // at t = 0 Jupiter is at (0, -a), moving along +x
  return {p: [A_J * Math.cos(th), A_J * Math.sin(th)], v: [-V_J * Math.sin(th), V_J * Math.cos(th)]};
}

function closest(aim) {                                   // closest approach and turn for an aim in R_J
  const b = Math.max(Math.abs(aim), 0.05) * R_J, k = V_INF ** 2 / GM_J;
  const rp = (Math.sqrt(1 + (b * k) ** 2) - 1) / k;       // from b v∞ = r_p v_p and the energy
  const vp = Math.sqrt(V_INF ** 2 + 2 * GM_J / rp);
  const delta = 2 * Math.asin(1 / (1 + rp * k));          // sin(δ/2) = 1/e
  return {rp, vp, delta};
}

const stepSize = (d, w) => Math.min(3600, 0.01 * len(d) / len(w));   // s; short where it is close and fast

function accel(r, t) {                                    // the Sun's pull plus Jupiter's
  const d = sub(r, jupiter(t).p);
  return add(scale(r, -GM_SUN / len(r) ** 3), scale(d, -GM_J / len(d) ** 3));
}

// ---- the launch: start at closest approach, run Jupiter's pull alone backward to 300 R_J
function launch(aim) {
  const {rp, vp, delta} = closest(aim), s = aim >= 0 ? 1 : -1;
  const psi = PHI + s * delta / 2;                        // v∞,in turned by half the turn
  let v = [vp * Math.cos(psi), vp * Math.sin(psi)];
  let r = scale([Math.cos(psi - s * Math.PI / 2), Math.sin(psi - s * Math.PI / 2)], rp);   // at right angles
  let t = 0;
  while (len(r) < R_START) {
    const h = -stepSize(r, v);                            // the same step, backward in time
    v = add(v, scale(r, -0.5 * h * GM_J / len(r) ** 3));
    r = add(r, scale(v, h));
    v = add(v, scale(r, -0.5 * h * GM_J / len(r) ** 3));
    t += h;
  }
  const J = jupiter(t);
  return {t, r: add(J.p, r), v: add(J.v, v)};
}

function prepare() {                                      // the probe at its start, nothing flown yet
  const L = launch(state.aim);
  const rel = sub(L.v, jupiter(L.t).v);
  Object.assign(state, {t: L.t, r: L.r, v: L.v, running: false, done: false, hit: false, rmin: Infinity,
    speeds: {sunIn: len(L.v), jupIn: len(rel)}, trail: []});
  record();
}

function record() {                                       // one point of the path, in both frames, in R_J
  const J = jupiter(state.t), J0 = jupiter(0).p;
  state.trail.push([...scale(sub(state.r, J0), 1 / R_J), ...scale(sub(state.r, J.p), 1 / R_J)]);
}

// ---- one Verlet step, and one tick of play
function step() {
  const J = jupiter(state.t);
  const h = stepSize(sub(state.r, J.p), sub(state.v, J.v));
  state.v = add(state.v, scale(accel(state.r, state.t), h / 2));   // half a kick
  state.r = add(state.r, scale(state.v, h));                        // a drift
  state.t += h;
  state.v = add(state.v, scale(accel(state.r, state.t), h / 2));   // half a kick
  const d = len(sub(state.r, jupiter(state.t).p));
  state.rmin = Math.min(state.rmin, d);
  record();
  if (d < R_J) Object.assign(state, {running: false, hit: true});
  else if (state.t > 0 && d >= R_START) {
    Object.assign(state, {running: false, done: true});
    state.dots.push([state.aim, len(state.v) - state.speeds.sunIn]);
  }
}

function tick(dt) {
  const end = state.t + Math.min(dt, 0.05) * WARPS[state.warp] * DAY;   // the cap stops jumps
  while (state.running && state.t < end) step();
}

// ---- drawing
const $ = (id) => document.getElementById(id);
const color = (name) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim();
const FONT = "14px system-ui, sans-serif";
const RJ = "R<sub>J</sub>";
const views = {};

function fit(id) {                                        // size a canvas to its box, sharp on any screen
  const c = $(id), box = c.parentElement.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
  c.width = Math.round(box.width * dpr);
  c.height = Math.round(box.height * dpr);
  const ctx = c.getContext("2d");
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  return {ctx, w: box.width, h: box.height};
}

function layout() {
  const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight;
  document.body.className = H > W ? "upright" : "wide";
  for (const id of ["sun", "jup", "strip"]) views[id] = fit(id);
  const [x0, x1, y0, y1] = DATA.sun_view, s = views.sun;
  s.k = Math.min((s.w - 8) / (x1 - x0), (s.h - 24) / (y1 - y0));   // px per R_J; room for the title
  s.cx = s.w / 2 - ((x0 + x1) / 2) * s.k;
  s.cy = 22 + (s.h - 22) / 2 + ((y0 + y1) / 2) * s.k;
  const j = views.jup;
  j.k = Math.min(j.w, j.h) / 90;                          // ±45 R_J in the shorter direction
  j.cx = j.w / 2;
  j.cy = j.h / 2;
  render();
}

const px = (v, x, y) => [v.cx + x * v.k, v.cy - y * v.k];

function line(ctx, pts, style, width, dash = []) {
  ctx.beginPath();
  pts.forEach(([x, y], i) => (i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)));
  ctx.strokeStyle = style;
  ctx.lineWidth = width;
  ctx.setLineDash(dash);
  ctx.stroke();
  ctx.setLineDash([]);
}

function disk(ctx, [x, y], r, style) {
  ctx.beginPath();
  ctx.arc(x, y, r, 0, 2 * Math.PI);
  ctx.fillStyle = style;
  ctx.fill();
}

function label(ctx, text, x, y, style, align = "left") {
  ctx.font = FONT;
  ctx.textAlign = align;
  ctx.fillStyle = style;
  ctx.fillText(text, x, y);
}

function drawSun(c) {                                     // the Sun's frame: the scene
  const v = views.sun, ctx = v.ctx, J0 = jupiter(0).p;
  ctx.clearRect(0, 0, v.w, v.h);
  const at = (p) => px(v, (p[0] - J0[0]) / R_J, (p[1] - J0[1]) / R_J);
  const track = [];
  for (let x = -1.2; x <= 1.2; x += 0.05) track.push(at(jupiter((x * v.w / v.k * R_J) / V_J).p));
  line(ctx, track, c.muted, 1);
  const J = jupiter(state.t);
  ctx.beginPath();
  ctx.arc(...at(J.p), R_START / R_J * v.k, 0, 2 * Math.PI);   // where the flight starts and ends
  ctx.strokeStyle = c.muted;
  ctx.lineWidth = 1;
  ctx.setLineDash([4, 4]);
  ctx.stroke();
  ctx.setLineDash([]);
  line(ctx, state.trail.map((p) => px(v, p[0], p[1])), c.accent, 2);
  disk(ctx, at(J.p), Math.max(5, v.k), c.ink);            // not to scale here: at least 5 px
  if (!state.hit) disk(ctx, at(state.r), 4, c.accent);
  label(ctx, "Sun's frame", 6, 16, c.fg);
  label(ctx, "↑ to the Sun", v.w - 6, 16, c.muted, "right");
}

function drawJupiter(c) {                                 // Jupiter's frame: the hyperbola and the aim
  const v = views.jup, ctx = v.ctx;
  ctx.clearRect(0, 0, v.w, v.h);
  const aimAt = (b) => px(v, b * N_AIM[0], b * N_AIM[1]);
  line(ctx, [aimAt(-DATA.aim_max), aimAt(DATA.aim_max)], c.muted, 1);
  const [bx, by] = aimAt(state.aim);
  line(ctx, [[bx - 200 * v.k * U_IN[0], by + 200 * v.k * U_IN[1]], [bx, by]], c.muted, 1.2, [5, 4]);
  disk(ctx, [v.cx, v.cy], Math.max(2, v.k), c.ink);       // to scale: 1 R_J
  line(ctx, state.trail.map((p) => px(v, p[2], p[3])), c.second, 2);
  const last = state.trail[state.trail.length - 1];
  if (!state.hit) disk(ctx, px(v, last[2], last[3]), 4, c.second);
  ctx.beginPath();
  ctx.arc(bx, by, 7, 0, 2 * Math.PI);
  ctx.strokeStyle = c.fg;
  ctx.lineWidth = 2;
  ctx.stroke();
  const end = (b, text) => {                              // a name just past each end of the aim line
    const [x0, y0] = aimAt(b), [x1, y1] = aimAt(b * 1.001), out = Math.sign(x1 - x0);
    ctx.textBaseline = "middle";
    label(ctx, text, x0 + out * 10, Math.min(v.h - 10, Math.max(30, y0 + Math.sign(y1 - y0) * 8)), c.muted,
      out > 0 ? "left" : "right");
    ctx.textBaseline = "alphabetic";
  };
  end(DATA.aim_max, "behind");
  end(-DATA.aim_max, "in front");
  label(ctx, "Jupiter's frame", 6, 16, c.fg);
  if (!state.running && !state.done && !state.hit) label(ctx, "drag to aim", v.w - 6, v.h - 6, c.muted, "right");
}

function drawStrip(c) {                                   // the gain for each aim: the notebook's curve, your launches
  const v = views.strip, ctx = v.ctx, g = DATA.gain_curve;
  ctx.clearRect(0, 0, v.w, v.h);
  const lo = Math.min(...g.gain) - 1, hi = Math.max(...g.gain) + 1, left = 70, right = 8;
  const at = (b, y) => [left + ((b + DATA.aim_max) / (2 * DATA.aim_max)) * (v.w - left - right),
    4 + ((hi - y) / (hi - lo)) * (v.h - 8)];
  line(ctx, [at(-DATA.aim_max, 0), at(DATA.aim_max, 0)], c.muted, 1);
  line(ctx, [at(-DATA.aim_max, 10), at(DATA.aim_max, 10)], c.muted, 1, [2, 3]);
  label(ctx, "gain 0", left - 6, at(0, 0)[1] + 5, c.muted, "right");
  label(ctx, "+10 km/s", left - 6, at(0, 10)[1] + 5, c.muted, "right");
  label(ctx, "in front", at(-9, 0)[0], v.h - 4, c.muted, "right");   // beside the gap where the probe hits
  label(ctx, "behind", at(9, 0)[0], v.h - 4, c.muted);
  for (const side of [-1, 1]) {                           // the hit band in the middle splits the curve
    const pts = g.aim.map((b, i) => [b, g.gain[i]]).filter(([b]) => b * side > 0);
    line(ctx, pts.map(([b, y]) => at(b, y)), c.ink, 1.5);
  }
  line(ctx, [at(state.aim, lo), at(state.aim, hi)], c.muted, 1, [3, 3]);   // your aim
  for (const [b, y] of state.dots) disk(ctx, at(b, y), 3.5, c.accent);
}

function render() {
  const c = Object.fromEntries(["fg", "ink", "accent", "second", "muted"].map((n) => [n, color(n)]));
  drawSun(c);
  drawJupiter(c);
  drawStrip(c);
  const J = jupiter(state.t), sp = state.speeds;
  $("v-sun").textContent = `${len(state.v).toFixed(2)} km/s`;
  $("v-sun-in").textContent = `in ${sp.sunIn.toFixed(2)}`;
  $("v-jup").textContent = `${len(sub(state.v, J.v)).toFixed(2)} km/s`;
  $("v-jup-in").textContent = `in ${sp.jupIn.toFixed(2)}`;
  const close = state.hit ? "hit Jupiter" : `closest ${(state.rmin / R_J).toFixed(2)} ${RJ}`;
  $("closest").innerHTML = state.rmin < Infinity ? `${close} · ` : "";
  $("clock").textContent = `t ${state.t < 0 ? "−" : "+"}${Math.abs(state.t / DAY).toFixed(1)} d`;
  $("aim-val").innerHTML = `${Math.abs(state.aim).toFixed(1)} ${RJ} ${state.aim >= 0 ? "behind" : "in front"}`;
  $("warp-val").textContent = ["1 h", "3 h", "12 h", "1 day", "2 days", "4 days"][state.warp] + "/s";
  $("go").textContent = state.running ? "Pause" : state.done || state.hit || state.rmin === Infinity ? "Launch" : "Resume";
}

// ---- controls, keys, dragging, and the loop
function set(partial) {
  Object.assign(state, partial);
  if ("aim" in partial) {
    state.aim = Math.min(DATA.aim_max, Math.max(-DATA.aim_max, Math.round(state.aim * 20) / 20));
    prepare();
  }
  render();
}
const nudgeAim = (sign) => set({aim: state.aim + sign * DATA.aim_step});
const nudgeWarp = (sign) => set({warp: Math.min(WARPS.length - 1, Math.max(0, state.warp + sign))});
function go() {
  if (state.done || state.hit) prepare();
  set({running: !state.running});
}
const reset = () => set({...DEFAULTS, dots: []});

$("aim-minus").addEventListener("click", () => nudgeAim(-1));
$("aim-plus").addEventListener("click", () => nudgeAim(1));
$("warp-minus").addEventListener("click", () => nudgeWarp(-1));
$("warp-plus").addEventListener("click", () => nudgeWarp(1));
$("go").addEventListener("click", go);
$("reset").addEventListener("click", reset);
document.addEventListener("keydown", (e) => {
  const actions = {ArrowLeft: () => nudgeAim(-1), ArrowRight: () => nudgeAim(1), " ": go,
    "[": () => nudgeWarp(-1), "]": () => nudgeWarp(1), r: reset};
  const act = actions[e.key.length === 1 ? e.key.toLowerCase() : e.key];
  if (act && !(e.target instanceof HTMLButtonElement && e.key === " ")) {
    e.preventDefault();
    act();
  }
});

// A drag moves the aim by the pointer's motion along the aim line, geared down by half,
// so the finger never covers the handle it moves.
let drag = null;
const jup = $("jup");
jup.addEventListener("pointerdown", (e) => {
  drag = {x: e.clientX, y: e.clientY, aim: state.aim};
  jup.setPointerCapture(e.pointerId);
});
jup.addEventListener("pointermove", (e) => {
  if (!drag) return;
  const along = (e.clientX - drag.x) * N_AIM[0] - (e.clientY - drag.y) * N_AIM[1];
  set({aim: drag.aim + (0.5 * along) / views.jup.k});
});
for (const type of ["pointerup", "pointercancel"]) jup.addEventListener(type, () => { drag = null; });

let last = null;
function frame(now) {                                     // called by the browser once per screen refresh
  const dt = last === null ? 0 : (now - last) / 1000;
  last = now;
  if (state.running) {
    tick(dt);
    render();
  }
  requestAnimationFrame(frame);
}

window.addEventListener("resize", layout);
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
prepare();
layout();
requestAnimationFrame(frame);
window.game = {state, set, render};

In Jupiter's frame the speed does not change

In Jupiter's view the probe swings around the planet on a curve that is the same going in and coming out, at 8.34 km/s in and 8.33 km/s out relative to Jupiter. The notebook draws the stills from the game's own model, in the hidden cell below with Voyager 2's states from JPL Horizons: the Sun fixed, Jupiter on a circular orbit of 778.5 million km at 13.06 km/s, and the probe pulled by both, in the plane of the ecliptic.

Show code
from functools import cache
from pathlib import Path
import json

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Arc, Circle
from scipy.integrate import solve_ivp
from scipy.optimize import brentq, minimize_scalar

plt.rcParams.update({
    "figure.figsize": (7, 3.6), "figure.dpi": 110,
    "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.alpha": 0.25,
    "font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"

GM_SUN, GM_J = 1.32712440018e11, 1.26686534e8      # km³/s²
R_J, A_J = 71492.0, 778.479e6                       # Jupiter's equatorial radius and orbit radius / km
G, M_PROBE = 6.6743e-11, 722.0                      # m³ kg⁻¹ s⁻², Voyager 2's launch mass / kg
DAY = 86400.0
V_J = np.sqrt(GM_SUN / A_J)                         # km/s on a circular orbit
OMEGA = V_J / A_J
R_START = 300 * R_J                                 # the flight starts and ends this far from Jupiter

# JPL Horizons, target -32 (Voyager 2), vector tables, ecliptic and mean equinox of J2000, km and km/s,
# queried 2026-10-17. Center 500@599 (Jupiter) for the states, 500@10 (the Sun) for the velocities.
H_IN = dict(r=[3.4245e6, -24.4049e6, 2.3119e6], v=[-0.5704, 8.2297, -0.8167])      # 1979-06-09 00:00 TDB
H_OUT = dict(r=[-23.0807e6, -2.5746e6, 1.7505e6], v=[-8.0982, -1.5211, 0.6781])    # 1979-08-08 00:00 TDB
H_SUN_IN = [-9.8402, -0.2970, -0.6009]              # 1979-06-20 00:00 TDB, relative to the Sun
H_SUN_OUT = [-17.0452, -10.8465, 0.9299]            # 1979-07-30 00:00 TDB
H_JUPITER = [-8.9719, -9.0550, 0.2382]              # Jupiter relative to the Sun, 1979-07-09 22:00 TDB
H_CLOSEST = 721375.0                                # km from Jupiter's center, 1979-07-09 about 22:30 TDB

def v_inf_of(state):
    """Speed far from Jupiter, from a Jupiter-frame state: ½v² − GM/r stays fixed."""
    r, v = np.linalg.norm(state["r"]), np.linalg.norm(state["v"])
    return np.sqrt(v**2 - 2 * GM_J / r)

cos_approach = np.dot(H_IN["v"][:2], H_JUPITER[:2]) / np.linalg.norm(H_IN["v"][:2]) / np.linalg.norm(H_JUPITER[:2])
APPROACH_H = np.degrees(np.arccos(cos_approach))    # in the ecliptic plane
V_INF, APPROACH = 7.6, 131.0                        # what the game flies: Horizons, rounded

# ---- the game's model in Python, same names as in game.js. x along Jupiter's motion at t = 0, y toward the Sun.
def rot(angle):
    return np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])

PHI = -np.radians(APPROACH)                         # direction of v∞,in, measured from Jupiter's motion
U_IN = np.array([np.cos(PHI), np.sin(PHI)])
N_AIM = rot(-np.pi / 2) @ U_IN                      # the aim line points this way for a pass behind

def jupiter(t):
    """Jupiter's position and velocity on its circle; at t = 0 it is at (0, -a), moving along +x."""
    th = -np.pi / 2 + OMEGA * t
    return A_J * np.array([np.cos(th), np.sin(th)]), V_J * np.array([-np.sin(th), np.cos(th)])

def closest(aim):
    """Closest approach r_p / km, speed there v_p / (km/s), and turn δ / rad, for an aim in R_J."""
    b, k = abs(aim) * R_J, V_INF**2 / GM_J
    rp = (np.sqrt(1 + (b * k) ** 2) - 1) / k
    vp = np.sqrt(V_INF**2 + 2 * GM_J / rp)
    delta = 2 * np.arcsin(1 / (1 + rp * k))
    return rp, vp, delta

def turn(aim):
    """v∞ in and out, and the Sun-frame velocities far from Jupiter in and out (closed form)."""
    delta = closest(aim)[2]
    v_inf_in = V_INF * U_IN
    v_inf_out = rot(np.sign(aim) * delta) @ v_inf_in  # behind: toward Jupiter's motion
    V = np.array([V_J, 0.0])
    return v_inf_in, v_inf_out, V + v_inf_in, V + v_inf_out

def gain(aim):
    _, _, v_in, v_out = turn(aim)
    return np.linalg.norm(v_out) - np.linalg.norm(v_in)

def periapsis(aim):
    """The state at closest approach, Jupiter's frame: v∞,in turned by δ/2, the position at right angles."""
    rp, vp, delta = closest(aim)
    s = 1 if aim >= 0 else -1
    u = rot(s * delta / 2) @ U_IN
    return rp * (rot(-s * np.pi / 2) @ u), vp * u

def two_body(t, y):
    return np.r_[y[2:], -GM_J * y[:2] / np.linalg.norm(y[:2]) ** 3]

def out_there(t, y):
    return np.linalg.norm(y[:2]) - R_START
out_there.terminal = True

TOL = dict(method="DOP853", rtol=1e-11, atol=1e-6)

def launch_ivp(aim):
    """Back from closest approach with Jupiter alone to 300 R_J; the start state in the Sun's frame, and T."""
    sol = solve_ivp(two_body, (0, -100 * DAY), np.r_[periapsis(aim)], events=out_there, dense_output=True, **TOL)
    T = -sol.t_events[0][0]
    p, v = jupiter(-T)
    y = sol.y_events[0][0]
    return np.r_[p + y[:2], v + y[2:]], T, sol

def rhs(t, y):
    """The probe in the Sun's frame, pulled by the fixed Sun and by Jupiter on its circle."""
    r, d = y[:2], y[:2] - jupiter(t)[0]
    return np.r_[y[2:], -GM_SUN * r / np.linalg.norm(r) ** 3 - GM_J * d / np.linalg.norm(d) ** 3]

def relative(t, y):
    p, v = jupiter(t)
    return y[:2] - p, y[2:] - v

def leaves(t, y):
    d, w = relative(t, y)
    return np.linalg.norm(d) - R_START if d @ w > 0 else -1.0
leaves.terminal = True

def passes(t, y):
    d, w = relative(t, y)
    return d @ w
passes.direction = 1

@cache
def fly(aim):
    """One flight, 300 R_J in to 300 R_J out: speeds in both frames, measured closest approach, path."""
    y0, T, _ = launch_ivp(aim)
    sol = solve_ivp(rhs, (-T, T + 60 * DAY), y0, events=[leaves, passes], dense_output=True, **TOL)
    t_out, y_out = sol.t_events[0][0], sol.y_events[0][0]
    t_p, y_p = sol.t_events[1][0], sol.y_events[1][0]
    t = np.linspace(-T, t_out, 1500)
    y = sol.sol(t)
    J = np.array([jupiter(ti)[0] for ti in t]).T
    return dict(T=T, t_out=t_out, sol=sol, t=t,
                sun=(np.linalg.norm(y0[2:]), np.linalg.norm(y_out[2:])),
                jup=(np.linalg.norm(relative(-T, y0)[1]), np.linalg.norm(relative(t_out, y_out)[1])),
                rp=np.linalg.norm(relative(t_p, y_p)[0]) / R_J,
                scene=(y[:2] - jupiter(0)[0][:, None]) / R_J,   # Sun's frame, from Jupiter's place at t = 0
                rel=(y[:2] - J) / R_J)                        # Jupiter's frame

# ---- the game's Verlet loop, ported line by line
def step_size(d, w):
    return min(3600.0, 0.01 * np.linalg.norm(d) / np.linalg.norm(w))

def accel(r, t):
    d = r - jupiter(t)[0]
    return -GM_SUN * r / np.linalg.norm(r) ** 3 - GM_J * d / np.linalg.norm(d) ** 3

def launch(aim):
    r, v = periapsis(aim)
    t, n = 0.0, 0
    while np.linalg.norm(r) < R_START:
        h = -step_size(r, v)
        v = v - 0.5 * h * GM_J * r / np.linalg.norm(r) ** 3
        r = r + h * v
        v = v - 0.5 * h * GM_J * r / np.linalg.norm(r) ** 3
        t, n = t + h, n + 1
    p, w = jupiter(t)
    return dict(t=t, r=p + r, v=w + v, rmin=np.inf, n_back=n, n=0)

def step(state):
    p, w = jupiter(state["t"])
    h = step_size(state["r"] - p, state["v"] - w)
    state["h_min"] = min(state.get("h_min", h), h)
    state["v"] = state["v"] + h / 2 * accel(state["r"], state["t"])
    state["r"] = state["r"] + h * state["v"]
    state["t"] += h
    state["v"] = state["v"] + h / 2 * accel(state["r"], state["t"])
    state["rmin"] = min(state["rmin"], np.linalg.norm(state["r"] - jupiter(state["t"])[0]))
    state["n"] += 1

AIM_VOYAGER = H_CLOSEST * np.sqrt(1 + 2 * GM_J / (H_CLOSEST * V_INF**2)) / R_J   # the aim that gives r_p
print(f"Horizons: v∞ = {v_inf_of(H_IN):.2f} km/s in, {v_inf_of(H_OUT):.2f} km/s out; "
      f"approach {APPROACH_H:.1f}° from Jupiter's motion.  Game: v∞ = {V_INF} km/s at {APPROACH:.0f}°, V_J = {V_J:.2f} km/s")
Horizons: v∞ = 7.65 km/s in, 7.58 km/s out; approach 131.3° from Jupiter's motion.  Game: v∞ = 7.6 km/s at 131°, V_J = 13.06 km/s

Here is Voyager's aim, 26.9 \(R_J\) behind Jupiter, between two other aims, 12 and 40 \(R_J\) behind:

Show code
def hyperbola(aim, days=6):
    """Jupiter alone, both ways from closest approach: the two-body path, in R_J."""
    halves = [solve_ivp(two_body, (0, sign * days * DAY), np.r_[periapsis(aim)], dense_output=True, **TOL)
              for sign in [-1, 1]]
    t = np.linspace(0, days * DAY, 600)
    return np.hstack([halves[0].sol(-t[::-1])[:2], halves[1].sol(t)[:2]]) / R_J

fig, ax = plt.subplots(figsize=(7, 4.2))
rp_v, vp_v, delta_v = closest(AIM_VOYAGER)
for aim in [12.0, 40.0]:
    ax.plot(*fly(aim)["rel"], color=SECOND, alpha=0.45, lw=1.4)
ax.plot(*hyperbola(AIM_VOYAGER), color=INK, lw=1)
ax.plot(*fly(AIM_VOYAGER)["rel"], color=SECOND, lw=1.8)
ax.add_patch(Circle((0, 0), 1, color=INK, zorder=3))
b, n_out, u_out = AIM_VOYAGER, rot(delta_v) @ N_AIM, rot(delta_v) @ U_IN
for u, n in [(U_IN, N_AIM), (u_out, n_out)]:          # the two asymptotes, straight lines at distance b
    ax.plot(*(b * n[:, None] + u[:, None] * np.array([-150, 150])), color=MUTED, lw=1, ls="--")
corner = np.linalg.solve(np.column_stack([U_IN, -u_out]), b * (n_out - N_AIM))[0] * U_IN + b * N_AIM
start = np.degrees(np.arctan2(U_IN[1], U_IN[0]))
ax.add_patch(Arc(corner, 24, 24, theta1=start, theta2=start + np.degrees(delta_v), color=MUTED, lw=1.2))
ax.text(*(corner + 14 * (rot(delta_v / 2) @ U_IN)), f"δ = {np.degrees(delta_v):.1f}°", color=INK, ha="center", va="center")
ax.plot([0, b * N_AIM[0]], [0, b * N_AIM[1]], color=MUTED, lw=1.2)
ax.text(*(b * N_AIM + [-2, 3]), f"b = {b:.1f} R$_J$", color=MUTED, ha="right", va="bottom")
x_p = periapsis(AIM_VOYAGER)[0] / R_J
ax.plot([0, x_p[0]], [0, x_p[1]], color=INK, lw=1.2)
ax.annotate(f"r$_p$ = {rp_v / R_J:.1f} R$_J$", x_p / 2, xytext=(4, -12), color=INK, ha="left", va="top",
            arrowprops=dict(arrowstyle="-", color=INK, lw=0.8, shrinkA=2, shrinkB=1))
f_v = fly(AIM_VOYAGER)
ax.text(9, 42, f"in {f_v['jup'][0]:.3f} km/s", color=SECOND, va="top")
ax.text(31, -42, f"out {f_v['jup'][1]:.3f} km/s", color=SECOND, va="bottom")
for aim, xy in [(12.0, (48, -2)), (40.0, (-36, -30))]:
    ax.text(*xy, f"aim {aim:.0f}", color=SECOND, alpha=0.8, ha="center")
ax.set(xlabel="x / R$_J$, along Jupiter's motion", ylabel="y / R$_J$", xlim=(-75, 75), ylim=(-45, 45), aspect="equal")
plt.show()
Jupiter's frame, x and y in Jupiter radii. Probe paths for aims of 12, 26.9 (Voyager 2), and 40 R_J behind bend around Jupiter. Voyager's path is marked with its aim b = 26.9 R_J, its closest approach r_p = 10.1 R_J, and the turn of 97.6° between its straight asymptotes; it moves at 8.341 km/s in and 8.335 km/s out.

In Jupiter's frame this is a two-body problem, and the probe's energy per kilogram, \(\tfrac12 v^2 - GM_J/r\), stays fixed. At the same distance it has the same speed, so far away it leaves with the speed it came with, \(v_\infty\), the hyperbolic excess speed. Gravity pulls the path in, so the probe passes closer than its aim \(b\), the miss distance of the straight incoming line. Two conserved quantities fix how much closer: the angular momentum per kilogram, \(b\,v_\infty\) far away and \(r_p v_p\) at closest approach, and the energy, which gives \(v_p\). Together they give the closest approach \(r_p\):

\[v_p^2 = v_\infty^2 + \frac{2GM_J}{r_p}, \qquad b\,v_\infty = r_p v_p \quad\Rightarrow\quad r_p = \frac{GM_J}{v_\infty^2}\left(\sqrt{1 + \left(\frac{b\,v_\infty^2}{GM_J}\right)^2} - 1\right).\]

The path is a hyperbola, and all gravity does to the velocity is turn it, by the angle \(\delta\):

\[\sin\frac{\delta}{2} = \frac{1}{e}, \qquad e = 1 + \frac{r_p v_\infty^2}{GM_J}.\]

This is the standard two-body result (Curtis, in Further reading), with \(e\) the eccentricity, above 1 for a hyperbola.

print(f"aim b = {AIM_VOYAGER:.2f} R_J  ->  r_p = {rp_v / R_J:.2f} R_J, v_p = {closest(AIM_VOYAGER)[1]:.2f} km/s, "
      f"turn δ = {np.degrees(delta_v):.1f}°")
print(f"aim b = {12:5.2f} R_J  ->  r_p = {closest(12)[0] / R_J:.2f} R_J, turn δ = {np.degrees(closest(12)[2]):.1f}°")
print(f"Horizons, Voyager 2:  v∞ = {v_inf_of(H_IN):.2f} km/s in, {v_inf_of(H_OUT):.2f} km/s out")
print(f"game: v∞ = {V_INF} km/s far away, so {np.sqrt(V_INF**2 + 2 * GM_J / R_START):.3f} km/s at 300 R_J by the energy")
print(f"solve_ivp, relative to Jupiter:  {f_v['jup'][0]:.3f} km/s in, {f_v['jup'][1]:.3f} km/s out, at 300 R_J; "
      f"{f_v['T'] / DAY:.1f} days each way")
aim b = 26.85 R_J  ->  r_p = 10.09 R_J, v_p = 20.22 km/s, turn δ = 97.6°
aim b = 12.00 R_J  ->  r_p = 2.26 R_J, turn δ = 137.3°
Horizons, Voyager 2:  v∞ = 7.65 km/s in, 7.58 km/s out
game: v∞ = 7.6 km/s far away, so 8.341 km/s at 300 R_J by the energy
solve_ivp, relative to Jupiter:  8.341 km/s in, 8.335 km/s out, at 300 R_J; 26.4 days each way

Voyager's aim of 26.9 \(R_J\) gives \(r_p\) = 10.09 \(R_J\), Horizons' distance for the real flyby, and it turns the velocity by 97.6°; at 12 \(R_J\) the probe passes at 2.26 \(R_J\) and turns by 137.3°. The notebook flies the game's probe with solve_ivp, 26.4 days in and out between 300 \(R_J\) and closest approach, and reads 8.341 and 8.335 km/s. Both exceed \(v_\infty\) = 7.6 km/s, Horizons' 7.65 rounded, because 300 \(R_J\) is still inside Jupiter's well, where the energy gives 8.341 km/s. The 6 m/s between them, and the measured closest approach of 10.28 \(R_J\) instead of 10.09, are the Sun pulling a little differently on probe and planet over 53 days.

Behind the planet the probe leaves faster

Aimed behind Jupiter, at Voyager's aim, the probe's path in the Sun's view kinks forward at Jupiter and leaves at 20.37 km/s, twice the 10.21 km/s it came with:

Show code
def draw_scene(ax, flights):
    """The game's left view: Jupiter's track, Jupiter at start, closest approach, and end, the probe's paths."""
    f0 = fly(flights[0][0])
    t = np.linspace(-f0["T"] - 6 * DAY, f0["t_out"] + 6 * DAY, 200)
    track = (np.array([jupiter(ti)[0] for ti in t]).T - jupiter(0)[0][:, None]) / R_J
    ax.plot(*track, color=MUTED, lw=1)
    for ti, name in [(-f0["T"], "start"), (0.0, "closest"), (f0["t_out"], "end")]:
        p = (jupiter(ti)[0] - jupiter(0)[0]) / R_J
        ax.plot(*p, "o", ms=7, color=INK, zorder=3)
        ax.text(p[0], p[1] - 22, f"{name}\n{ti / DAY:+.0f} d", color=INK, ha="center", va="top", fontsize=10)
    p0 = (jupiter(-f0["T"])[0] - jupiter(0)[0]) / R_J
    ax.add_patch(Circle(p0, 300, fill=False, color=MUTED, lw=1, ls="--"))
    ax.text(*(p0 + 300 * np.array([np.cos(-0.9), np.sin(-0.9)]) + [12, 0]), "300 R$_J$", color=MUTED, va="top")
    for aim, alpha in flights:
        f = fly(aim)
        ax.plot(*f["scene"], color=ACCENT, alpha=alpha)
        end = f["scene"][:, -1]
        side = "behind" if aim > 0 else "in front"
        ax.text(end[0] + 12, end[1], f"out {f['sun'][1]:.2f} km/s\n{abs(aim):.1f} R$_J$ {side}", color=ACCENT,
                alpha=max(alpha, 0.7), va="center", linespacing=1.4)
    start = f0["scene"][:, 0]
    ax.text(start[0] + 15, start[1] + 10, f"in {f0['sun'][0]:.2f} km/s", color=ACCENT, va="bottom")
    ax.annotate("to the Sun", (0.93, 0.98), xytext=(0.93, 0.78), xycoords="axes fraction", color=MUTED,
                ha="center", va="top", arrowprops=dict(arrowstyle="->", color=MUTED, lw=1, relpos=(0.5, 1)))
    ax.set(xlabel="x / R$_J$", ylabel="y / R$_J$", xlim=(-500, 900), ylim=(-320, 360), aspect="equal")

fig, ax = plt.subplots(figsize=(7, 3.6))
draw_scene(ax, [(AIM_VOYAGER, 1.0)])
plt.show()
The Sun's frame in Jupiter radii, the Sun above. Jupiter moves right along its track over 53 days. The probe starts on the dashed circle of 300 R_J around Jupiter's start, aimed 26.9 R_J behind it, comes down from the upper left at 10.21 km/s, kinks forward at Jupiter, and leaves to the lower right at 20.37 km/s.

The probe's velocity relative to the Sun is Jupiter's velocity plus its velocity relative to Jupiter. The flyby keeps the length of \(v_\infty\) and turns it by \(\delta\): toward Jupiter's direction of motion for a pass behind, away from it for a pass in front. So the speed gained in the Sun's frame takes three steps:

\[\mathbf v_{\infty,\text{out}} = R(\pm\delta)\,\mathbf v_{\infty,\text{in}}, \qquad \mathbf v_\text{out} = \mathbf V_J + \mathbf v_{\infty,\text{out}}, \qquad \text{gain} = |\mathbf v_\text{out}| - |\mathbf V_J + \mathbf v_{\infty,\text{in}}|.\]

\(R(\pm\delta)\) is the rotation by \(\delta\), its sign set by the side of the pass. The velocity diagram draws the same three steps: Jupiter's velocity \(\mathbf V_J\), the circle of radius 7.6 km/s around its tip on which \(\mathbf v_\infty\) can point, \(\mathbf v_{\infty,\text{in}}\) at 131° from \(\mathbf V_J\), \(\mathbf v_{\infty,\text{out}}\) turned 97.6° toward it, and the two sums:

Show code
def arrow(ax, start, vec, color, **kw):
    ax.annotate("", start + vec, xytext=start,
                arrowprops=dict(arrowstyle="-|>", color=color, lw=kw.get("lw", 1.8), shrinkA=0, shrinkB=0, mutation_scale=12))

def draw_velocities(ax, aims, mirror=False, out_labels=((0.3, 0.3, "left", "bottom"),)):
    """V_J, the circle on which v∞ can point, v∞ in and out, and the Sun-frame sums."""
    V = np.array([V_J, 0.0])
    ax.add_patch(Circle(V, V_INF, fill=False, color=MUTED, lw=1))
    arrow(ax, np.zeros(2), V, INK)
    ax.text(0.72 * V_J, -0.4, f"V$_J$ = {V_J:.2f}", color=INK, ha="center", va="top")
    v_inf_in, _, v_in, _ = turn(aims[0])
    arrow(ax, V, v_inf_in, SECOND)
    arrow(ax, np.zeros(2), v_in, ACCENT, lw=1.4)
    ax.text(*(v_in / 2 + [-0.6, -0.4]), f"{np.linalg.norm(v_in):.2f}", color=ACCENT, ha="right", va="top")
    ax.text(*(V + v_inf_in * 1.08), "v$_{∞,in}$", color=SECOND, ha="center", va="top")
    if mirror:
        back = V + V_INF * np.array([-1.0, 0.0])
        ax.plot(*back, "|", ms=12, mew=1.5, color=MUTED)
        ax.text(back[0], back[1] + 0.6, "slowest", color=MUTED, ha="center", va="bottom")
        m = np.array([v_inf_in[0], -v_inf_in[1]])
        ax.plot(*np.column_stack([V, V + 1.25 * m]), color=MUTED, lw=1, ls="--")
        ax.text(*(V + 1.3 * m), "mirror of incoming", color=MUTED, ha="center", va="bottom")
    for aim, (dx, dy, ha, va) in zip(aims, out_labels):
        _, v_inf_out, _, v_out = turn(aim)
        arrow(ax, V, v_inf_out, SECOND)
        arrow(ax, np.zeros(2), v_out, ACCENT)
        ax.text(*(v_out + [dx, dy]), f"{np.linalg.norm(v_out):.2f}", color=ACCENT, ha=ha, va=va)
    ax.set(xlabel="v$_x$ / (km/s)", ylabel="v$_y$ / (km/s)", aspect="equal")

fig, ax = plt.subplots(figsize=(7, 4))
draw_velocities(ax, [AIM_VOYAGER])
start = np.degrees(PHI)
ax.add_patch(Arc((V_J, 0), 5, 5, theta1=start, theta2=start + np.degrees(delta_v), color=MUTED, lw=1.2))
ax.text(V_J + 4.6 * np.cos(np.radians(start) + delta_v / 2), 4.6 * np.sin(np.radians(start) + delta_v / 2),
        f"δ = {np.degrees(delta_v):.1f}°", color=INK, ha="center", va="center")
v_inf_out_v = turn(AIM_VOYAGER)[1]
ax.text(*(np.array([V_J, 0.0]) + 0.5 * v_inf_out_v + [0.3, 0.3]), "v$_{∞,out}$", color=SECOND, ha="left", va="bottom")
ax.set(xlim=(-1, 22), ylim=(-9, 3))
plt.show()
Velocity diagram in km/s. Jupiter's velocity, 13.06 along x; a circle of radius 7.6 around its tip; v∞ in, and v∞ out turned by 97.6° toward Jupiter's motion. The Sun-frame sums are 9.90 km/s in and 19.85 km/s out.

The same three steps in NumPy, for Voyager's aim:

V = np.array([V_J, 0.0])                                        # Jupiter's velocity at closest approach
v_inf_in = V_INF * np.array([np.cos(PHI), np.sin(PHI)])         # 131° from Jupiter's motion
R = np.array([[np.cos(delta_v), -np.sin(delta_v)],              # turn by δ toward Jupiter's motion
              [np.sin(delta_v), np.cos(delta_v)]])
v_inf_out = R @ v_inf_in
v_in, v_out = V + v_inf_in, V + v_inf_out
dv = v_inf_out - v_inf_in
print(f"far from Jupiter:  {np.linalg.norm(v_in):.2f} -> {np.linalg.norm(v_out):.2f} km/s relative to the Sun, "
      f"|Δv| = 2 v∞ sin(δ/2) = {np.linalg.norm(dv):.2f} km/s")
print(f"solve_ivp, 300 R_J: {f_v['sun'][0]:.2f} -> {f_v['sun'][1]:.2f} km/s")
print(f"Horizons:           {np.linalg.norm(H_SUN_IN):.2f} -> {np.linalg.norm(H_SUN_OUT):.2f} km/s (20 June, 30 July)")
print(f"ceiling V_J + v∞ = {V_J + V_INF:.2f} km/s, when v∞ ends up along V_J, a turn of {APPROACH:.0f}°")
far from Jupiter:  9.90 -> 19.85 km/s relative to the Sun, |Δv| = 2 v∞ sin(δ/2) = 11.44 km/s
solve_ivp, 300 R_J: 10.21 -> 20.37 km/s
Horizons:           9.86 -> 20.22 km/s (20 June, 30 July)
ceiling V_J + v∞ = 20.66 km/s, when v∞ ends up along V_J, a turn of 131°

Far from Jupiter the probe goes from 9.90 to 19.85 km/s. The game reads 10.21 and 20.37 km/s at 300 \(R_J\), where Jupiter has not yet let go, and Horizons has 9.86 and 20.22 km/s for the real flyby. The change of velocity, \(|\Delta\mathbf v| = 2 v_\infty \sin(\delta/2)\) = 11.44 km/s, is the same in both frames. The Sun-frame speed is largest, \(V_J + v_\infty\) = 20.66 km/s, when \(\mathbf v_\infty\) ends up along \(\mathbf V_J\), a turn of 131° from its incoming direction; a pass that turns it further overshoots and leaves slower than at 131°.

The 10 km/s came from somewhere. Jupiter pulled the probe forward, and the probe pulled Jupiter back with the same force for the same time, so \(M\,\Delta\mathbf V_J = -m\,\Delta\mathbf v\). Because \(|\mathbf v_\infty|\) is the same in and out, the probe's gain in kinetic energy per kilogram is \(\mathbf V_J\cdot\Delta\mathbf v\),

\[\tfrac12\left(v_\text{out}^2 - v_\text{in}^2\right) = \tfrac12\left(|\mathbf V_J + \mathbf v_{\infty,\text{out}}|^2 - |\mathbf V_J + \mathbf v_{\infty,\text{in}}|^2\right) = \mathbf V_J\cdot(\mathbf v_{\infty,\text{out}} - \mathbf v_{\infty,\text{in}}) = \mathbf V_J\cdot\Delta\mathbf v\]

and that is exactly what Jupiter loses:

M_J = GM_J * 1e9 / G                                            # km³ -> m³
m_over_M = M_PROBE / M_J
dV_momentum = -m_over_M * (V @ dv) / V_J * 1e3                  # m/s, along Jupiter's motion
dE = 0.5 * M_PROBE * (v_out @ v_out - v_in @ v_in) * 1e6        # J
dV_energy = -dE / (M_J * V_J * 1e3)                             # m/s, from M V_J ΔV_J = -ΔE
print(f"M_J = {M_J:.4g} kg, m/M = {m_over_M:.2g}")
print(f"Jupiter's speed changes by {dV_momentum:.2g} m/s (momentum) or {dV_energy:.2g} m/s (energy)")
print(f"the probe gains {dE:.3g} J")
M_J = 1.898e+27 kg, m/M = 3.8e-25
Jupiter's speed changes by -4.3e-21 m/s (momentum) or -4.3e-21 m/s (energy)
the probe gains 1.07e+11 J

With Voyager 2's launch mass of 722 kg, \(m/M\) = 3.8 × 10⁻²⁵: Jupiter slows by 4.3 × 10⁻²¹ m/s, by momentum and by energy alike, while the probe gains 1.07 × 10¹¹ J. That is why the game can keep Jupiter on its circle.

In front of the planet the probe can lose speed

Aim 40 \(R_J\) in front and the path kinks backward: the probe leaves at 6.97 km/s, slower than it came. Aim 12 \(R_J\) in front and it leaves at 15.67 km/s, faster than it came:

Show code
fig, ax = plt.subplots(figsize=(7, 3.6))
draw_scene(ax, [(-40.0, 1.0), (-12.0, 0.45)])
plt.show()
The Sun's frame as in the game, for two aims in front of Jupiter. Aimed 40 R_J in front, the probe leaves backward at 6.97 km/s; aimed 12 R_J in front, it leaves forward at 15.67 km/s. Both came in at about 10.1 km/s.

By the identity above, the probe gains exactly when \(\mathbf V_J\cdot\Delta\mathbf v > 0\): when \(\mathbf v_\infty\) leaves pointing closer to Jupiter's direction of motion than it came. Measure the direction of \(\mathbf v_\infty\) as its angle from Jupiter's motion. Voyager's comes in at 131°, pointing away from the Sun. A pass in front turns it the other way: the angle grows to 180°, straight backward, where the probe is slowest, and then shrinks again with \(\mathbf v_\infty\) now pointing toward the Sun. Only once it is below 131° there, past the mirror image of the incoming direction, does \(\mathbf v_\infty\) point closer to Jupiter's motion than it came. So a pass in front gains only when \(\delta\) exceeds 2 × (180° − 131°) = 98°:

Show code
fig, ax = plt.subplots(figsize=(7, 4))
draw_velocities(ax, [-40.0, -12.0], mirror=True, out_labels=[(-0.3, 0.5, "right", "bottom"), (0.3, 0.3, "left", "bottom")])
ax.set(xlim=(-1, 22), ylim=(-9, 9))
plt.show()
Velocity diagram for passes in front. v∞ in at 131° from Jupiter's velocity, the slowest point straight backward, and the mirror of the incoming direction dashed. The 40 R_J pass turns v∞ short of the mirror, 7.06 km/s out; the 12 R_J pass turns it past, 14.91 km/s out, against 9.90 km/s in.

The turns, the speeds, the aim where the gain changes sign, found with brentq on the closed form, and the largest gain, found with minimize_scalar:

for aim in [-40.0, -12.0]:
    print(f"{-aim:4.0f} R_J in front: turn {np.degrees(closest(aim)[2]):5.1f}°, "
          f"{np.linalg.norm(turn(aim)[2]):.2f} -> {np.linalg.norm(turn(aim)[3]):.2f} km/s far from Jupiter, "
          f"{fly(aim)['sun'][0]:.2f} -> {fly(aim)['sun'][1]:.2f} km/s at 300 R_J")
print(f"break-even turn 2 × (180° - {APPROACH:.0f}°) = {2 * (180 - APPROACH):.0f}°")
aim_even = brentq(gain, -40, -10)                               # gain changes sign between these aims
print(f"break-even aim {-aim_even:.1f} R_J in front, turn {np.degrees(closest(aim_even)[2]):.1f}°")
best = minimize_scalar(lambda aim: -gain(aim), bounds=(8, 40), method="bounded", options=dict(xatol=1e-6))
print(f"largest gain {-best.fun:+.2f} km/s at {best.x:.1f} R_J behind, turn {np.degrees(closest(best.x)[2]):.1f}°, "
      f"r_p = {closest(best.x)[0] / R_J:.1f} R_J")
  40 R_J in front: turn  75.0°, 9.90 -> 7.06 km/s far from Jupiter, 10.14 -> 6.97 km/s at 300 R_J
  12 R_J in front: turn 137.3°, 9.90 -> 14.91 km/s far from Jupiter, 10.15 -> 15.67 km/s at 300 R_J
break-even turn 2 × (180° - 131°) = 98°
break-even aim 26.7 R_J in front, turn 98.0°
largest gain +10.76 km/s at 14.0 R_J behind, turn 131.0°, r_p = 3.0 R_J

At 40 \(R_J\) in front the turn is 75.0°, short of the mirror, and far from Jupiter the probe loses 2.84 km/s; at 12 \(R_J\) the turn is 137.3°, past it. The break-even aim is 26.7 \(R_J\) in front, a turn of 98.0°, almost exactly Voyager's 97.6°: mirrored to the front, Voyager's pass would have left about as fast as it came. Here is the gain for every aim, the curve on which the game's strip chart puts a dot for each launch:

Show code
AIM_HIT = R_J * np.sqrt(1 + 2 * GM_J / (R_J * V_INF**2)) / R_J   # r_p = 1 R_J
aims = np.r_[-np.linspace(AIM_HIT, 40, 300)[::-1], np.linspace(AIM_HIT, 40, 300)]
dots = np.r_[-np.linspace(8.5, 40, 16)[::-1], np.linspace(8.5, 40, 16)]
fly_gain = np.array([fly(a)["sun"][1] - fly(a)["sun"][0] for a in dots])
fig, ax = plt.subplots(figsize=(7, 3.6))
ax.axvspan(-AIM_HIT, AIM_HIT, color=MUTED, alpha=0.35, lw=0)
ax.text(0, 5, "hits\nJupiter", color=INK, ha="center", va="center", fontsize=10)
ax.axhline(0, color=MUTED, lw=1)
for side in [aims < 0, aims > 0]:
    ax.plot(aims[side], [gain(a) for a in aims[side]], color=INK)
ax.plot(dots, fly_gain, "o", ms=4, color=ACCENT)
ax.axvline(AIM_VOYAGER, color=MUTED, lw=1, ls="--")
ax.text(AIM_VOYAGER + 0.8, -3.5, "Voyager 2", color=MUTED, va="bottom")
ax.annotate(f"+{-best.fun:.1f}", (best.x, -best.fun), xytext=(0, 8), textcoords="offset points", ha="center", color=INK)
ax.plot(aim_even, 0, "o", ms=6, mfc="none", mec=INK, mew=1.2)
ax.annotate("break even", (aim_even, 0), xytext=(8, -8), textcoords="offset points", ha="left", va="top", color=INK)
ax.text(-39, 11.5, "closed form", color=INK, va="center")
ax.plot(-38.4, 10.1, "o", ms=4, color=ACCENT)
ax.text(-37.4, 10.1, "solve_ivp at 300 R$_J$", color=ACCENT, va="center")
ax.set(xlabel="aim b / R$_J$  (in front < 0 < behind)", ylabel="speed gained, Sun's frame / (km/s)",
       xlim=(-41, 41), ylim=(-4, 12.5))
plt.show()
gap = np.abs(fly_gain - np.array([gain(a) for a in dots]))
print(f"solve_ivp at 300 R_J against the closed form: {gap.min():.2f} to {gap.max():.2f} km/s apart")
Speed gained relative to the Sun against aim, from 40 R_J in front to 40 R_J behind, with a band where the probe hits Jupiter. Line: closed form. Dots: solve_ivp at 300 R_J. Behind, the gain peaks at +10.8 km/s at 14 R_J and is +9.95 at Voyager's aim; in front it breaks even at 26.7 R_J and is −2.84 at 40.
solve_ivp at 300 R_J against the closed form: 0.02 to 0.56 km/s apart

The largest gain, +10.76 km/s, comes at 14.0 \(R_J\) behind and not at the closest pass, because that aim turns \(\mathbf v_\infty\) by 131°, onto \(\mathbf V_J\), and closer passes turn it past. The solve_ivp dots sit 0.02 to 0.56 km/s off the line, read at 300 \(R_J\) like the game's.

How the game computes it

The state. Everything that changes lives in one object, in km, km/s, and s: the time t from closest approach, the probe's position r and velocity v relative to the Sun, the aim in \(R_J\) (positive behind), the warp, three flags, the closest approach so far, the speeds it came in with, the path, and the strip chart's dots:

const DEFAULTS = {aim: DATA.aim_voyager, warp: 3, t: 0, r: [0, 0], v: [0, 0], running: false, done: false,
  hit: false, rmin: Infinity, speeds: null, trail: [], dots: []};

One tick. requestAnimationFrame runs frame before each repaint. It passes the wall-clock time elapsed to tick, which clips it to 50 ms and scales it by the warp. The clip keeps a tab returning from the background from flinging the probe ahead. Then tick advances in velocity Verlet steps until the probe's clock matches: half a kick, a drift, half a kick. Jupiter's position at any time comes from the circle formula. A plain Euler step would let the energy drift over a 26-day approach and change \(v_\infty\) itself, while Verlet's energy error stays bounded; Velocity Verlet explains the scheme:

function step() {
  const J = jupiter(state.t);
  const h = stepSize(sub(state.r, J.p), sub(state.v, J.v));
  state.v = add(state.v, scale(accel(state.r, state.t), h / 2));   // half a kick
  state.r = add(state.r, scale(state.v, h));                        // a drift
  state.t += h;
  state.v = add(state.v, scale(accel(state.r, state.t), h / 2));   // half a kick

The step is 1 % of the time the probe needs to cover its distance from Jupiter at its speed relative to Jupiter, at most an hour far out and 366 s at Voyager's closest approach:

const stepSize = (d, w) => Math.min(3600, 0.01 * len(d) / len(w));   // s; short where it is close and fast

The launch. The game puts the probe at closest approach with the formulas from Jupiter's frame: \(r_p\) from the aim, \(v_p\) from the energy, the velocity along \(\mathbf v_{\infty,\text{in}}\) turned by \(\delta/2\), since the hyperbola is symmetric, and the position \(r_p\) away at right angles to it. Then it runs the same step backward in time with Jupiter's pull alone until the probe is 300 \(R_J\) out, and starts the flight at that time with Jupiter's position and velocity added. Verlet runs backward as well as forward (Symplectic integrators says why), so the start lies on the hyperbola, and closest approach falls near \(t\) = 0 for every aim:

function launch(aim) {
  const {rp, vp, delta} = closest(aim), s = aim >= 0 ? 1 : -1;
  const psi = PHI + s * delta / 2;                        // v∞,in turned by half the turn
  let v = [vp * Math.cos(psi), vp * Math.sin(psi)];
  let r = scale([Math.cos(psi - s * Math.PI / 2), Math.sin(psi - s * Math.PI / 2)], rp);   // at right angles
  let t = 0;
  while (len(r) < R_START) {
    const h = -stepSize(r, v);                            // the same step, backward in time
    v = add(v, scale(r, -0.5 * h * GM_J / len(r) ** 3));
    r = add(r, scale(v, h));
    v = add(v, scale(r, -0.5 * h * GM_J / len(r) ** 3));
    t += h;
  }
  const J = jupiter(t);
  return {t, r: add(J.p, r), v: add(J.v, v)};
}

Jupiter's frame. The second view draws the same state minus Jupiter's position, so both views always show the same moment. Each step stores one point of the path in both frames, the Sun's measured from where Jupiter is at \(t\) = 0:

  state.trail.push([...scale(sub(state.r, J0), 1 / R_J), ...scale(sub(state.r, J.p), 1 / R_J)]);

From the notebook into the game. The notebook writes the constants, \(v_\infty\) = 7.6 km/s and the approach angle of 131° (Horizons' 7.65 km/s and 131.3°, rounded), Voyager's aim, and the closed-form gain curve for the strip chart, as JSON into a <script type="application/json" id="data"> element. Nothing is fetched. The first hidden cell also ports launch and step to Python under the same names; here they fly Voyager's aim against solve_ivp:

game = launch(AIM_VOYAGER)
t_start = game["t"]
while not (game["t"] > 0 and np.linalg.norm(game["r"] - jupiter(game["t"])[0]) >= R_START):
    step(game)
exact = f_v["sol"].sol(game["t"])                               # solve_ivp at the game's last time
print(f"game: start at t = {t_start / DAY:.2f} d after {game['n_back']} steps back, "
      f"{game['n']} steps forward to t = {game['t'] / DAY:+.2f} d")
print(f"game against solve_ivp at the end: {1e3 * np.linalg.norm(game['v'] - exact[2:]):.2f} m/s, "
      f"{np.linalg.norm(game['r'] - exact[:2]):.0f} km")
print(f"steps from {game['h_min']:.0f} s near Jupiter to {step_size([R_START, 0], [8.3, 0]):.0f} s far out")
print(f"closest approach in the game {game['rmin'] / R_J:.2f} R_J, solve_ivp {f_v['rp']:.2f} R_J, "
      f"Jupiter alone {rp_v / R_J:.2f} R_J")
game: start at t = -26.47 d after 831 steps back, 1656 steps forward to t = +26.51 d
game against solve_ivp at the end: 0.21 m/s, 666 km
steps from 366 s near Jupiter to 3600 s far out
closest approach in the game 10.28 R_J, solve_ivp 10.28 R_J, Jupiter alone 10.09 R_J

After 831 steps back and 1,656 forward the game ends within 0.21 m/s of solve_ivp, at the readout's closest approach of 10.28 \(R_J\).

The cell that writes the game. Fixed rounding and sorted keys make every run produce an identical file, and escaping </ as <\/ stops any string in the data from ending the script element. The HTML page is a template string, in the collapsed cell below:

Show code
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Gravity assist: aim Voyager 2 past Jupiter</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab; }
}
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--fg);
  font: 15px/1.3 system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { display: grid; gap: 6px 10px; padding: 6px; box-sizing: border-box;
  grid-template: "sun jup" minmax(0, 1fr) "strip read" auto "ctrl ctrl" auto / minmax(0, 1.6fr) minmax(0, 1fr); }
body.upright { font-size: 14px; gap: 4px; padding: 4px 6px;
  grid-template: "sun" minmax(0, 0.8fr) "jup" minmax(0, 1fr) "read" auto "strip" auto "ctrl" auto / minmax(0, 1fr); }
.box { position: relative; min-height: 0; }
.box canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#sunbox { grid-area: sun; } #jupbox { grid-area: jup; } #stripbox { grid-area: strip; height: 64px; }
#jup { touch-action: none; }
#read { grid-area: read; display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; align-content: start; }
.big { font-size: 1.45em; line-height: 1.2; font-variant-numeric: tabular-nums; }
.sun { color: var(--accent); } .jup { color: var(--second); }
.in { color: var(--muted); font-variant-numeric: tabular-nums; }
#info { grid-column: 1 / -1; font-variant-numeric: tabular-nums; }
#ctrl { grid-area: ctrl; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; }
.group { display: flex; align-items: center; gap: 6px; }
#aim-val { min-width: 8.2em; text-align: center; font-variant-numeric: tabular-nums; }
#warp-val { min-width: 4.6em; text-align: center; }
button { font: inherit; color: var(--bg); background: var(--second); border: 0; border-radius: 4px; padding: 0.3em 0.9em; }
button.step { padding: 0; width: 2em; }
#go { background: var(--accent); min-width: 5.2em; }
button { touch-action: manipulation; }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }
</style>
</head>
<body>
<div class="box" id="sunbox"><canvas id="sun" aria-label="The Sun's frame: Jupiter on its track and the probe's path"></canvas></div>
<div class="box" id="jupbox"><canvas id="jup" aria-label="Jupiter's frame: the probe's hyperbola and the aim line"></canvas></div>
<div id="read">
  <div class="sun">relative to the Sun<div class="big" id="v-sun"></div><div class="in" id="v-sun-in"></div></div>
  <div class="jup">relative to Jupiter<div class="big" id="v-jup"></div><div class="in" id="v-jup-in"></div></div>
  <div id="info"><span id="closest"></span><span id="clock"></span></div>
</div>
<div class="box" id="stripbox"><canvas id="strip" aria-label="Speed gained against aim: the notebook's curve and your launches"></canvas></div>
<div id="ctrl">
  <div class="group"><span>Aim</span><button class="step" id="aim-minus" title="Aim further in front (←)" aria-label="Aim further in front">−</button><span
    id="aim-val"></span><button class="step" id="aim-plus" title="Aim further behind (→)" aria-label="Aim further behind">+</button></div>
  <div class="group"><button id="go" title="Launch or pause (Space)"></button><button id="reset" title="Reset (R)">Reset</button></div>
  <div class="group"><span>Warp</span><button class="step" id="warp-minus" title="Slower ([)" aria-label="Slower">−</button><span
    id="warp-val"></span><button class="step" id="warp-plus" title="Faster (])" aria-label="Faster">+</button></div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
curve = np.r_[-np.arange(40, AIM_HIT, -0.5), np.arange(np.ceil(2 * AIM_HIT) / 2, 40.01, 0.5)]
scene = np.hstack([fly(a)["scene"] for a in dots])
game_data = {
    "gm_sun": GM_SUN, "gm_j": GM_J, "r_j": R_J, "a_j": A_J, "v_inf": V_INF, "approach_deg": APPROACH,
    "aim_voyager": round(AIM_VOYAGER, 2), "aim_max": 40, "aim_step": 0.5, "r_start": 300,
    "sun_view": [-460, 900, -320, 340],
    "gain_curve": {"aim": [round(float(a), 2) for a in curve], "gain": [round(float(gain(a)), 2) for a in curve]},
}
# sorted keys and fixed rounding: two runs write the same bytes; "<\/" keeps "</" out of the script element
data = json.dumps(game_data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
script = Path("minigame/flyby/game.js").read_text(encoding="utf-8")
Path("assets").mkdir(exist_ok=True)
Path("assets/flyby.html").write_text(TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script),
                                     encoding="utf-8", newline="\n")
print(f"assets/flyby.html: {len(curve)} points of the gain curve, {Path('assets/flyby.html').stat().st_size / 1024:.0f} kB")
assets/flyby.html: 130 points of the gain curve, 19 kB

Variations

  • Out of the ecliptic (Ulysses). Give the state a third coordinate and aim above or below Jupiter: the turn then tips the plane of the orbit and changes the speed in one go. Ulysses passed Jupiter in February 1992, came away slower relative to the Sun, and left on an orbit tilted about 80° to the ecliptic.
  • Slow down at Venus (MESSENGER). Swap in Venus, \(GM\) = 3.2486 × 10⁵ km³/s², \(R\) = 6,052 km, 0.723 AU, 35.0 km/s, and aim in front, to lose speed on the way to Mercury. For an assumed \(v_\infty\) of 5 km/s a grazing pass turns \(\mathbf v_\infty\) by 86.0°, so no pass changes the Sun-frame speed by more than 6.82 km/s.
  • Graze the cloud tops. Drag the aim to 7.90 \(R_J\) behind, where Jupiter alone would bring the probe to its cloud tops: the largest turn, 151.1°, and the largest \(|\Delta\mathbf v|\), 14.7 km/s, yet a gain of +10.46 km/s against +10.76, because it turns \(\mathbf v_\infty\) past \(\mathbf V_J\). The Sun, pulling a little differently on probe and planet, holds the game's probe off at 1.08 \(R_J\) there; it first hits Jupiter at 7.60 \(R_J\).
  • Voyager 1. Type in its Horizons states, target −31, closest approach on 5 March 1979, and the same game flies its \(v_\infty\) and approach angle.

Further reading

Was this tutorial helpful? Sign in to tell the author with one click.

Found a mistake, or something unclear? Report a problem (with a free account).

Cite this tutorial

SciStack (2026). Gravity assist past Jupiter: faster in the Sun's frame, not in Jupiter's. https://scistack.dev/t/py-gravity-assist/ (accessed 2026-10-17).

@online{scistack-py-gravity-assist,
  author  = {{SciStack}},
  title   = {Gravity assist past Jupiter: faster in the Sun's frame, not in Jupiter's},
  date    = {2026-10-17},
  url     = {https://scistack.dev/t/py-gravity-assist/},
  urldate = {2026-10-17},
  note    = {numpy 2.4.3, scipy 1.18.1, matplotlib 3.11.2}
}

Tags

brentqcanvasgravity-assistjavascriptmatplotlibminimize_scalarnumpyorbital-mechanicsscipy.integratescipy.optimizesolve_ivp

Comments

No comments yet.

Sign in to comment, with a free account.