Skip to content
SciStack
Minigame Python Beginner 25 min

The Moon's path around the Sun: no loops, and the Sun pulls it twice as hard

Afterwards you can compare the Sun's and Earth's pull on the Moon, explain why its path around the Sun is convex and has no loops, and compute a Hill sphere.

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

py-moon-path.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: follow the Moon around the Sun

The Moon circles the Earth at 384,000 km and 1.0 km/s, while the Earth carries both around the Sun at 30 km/s. Textbooks draw the Moon's path around the Sun as a chain of loops. The game integrates the three bodies live, in the plane of the Earth's orbit, and draws their trails. Drag the Zoom slider, or tap its − and +, from the Earth out to the Sun and watch the Moon's trail. The arrows on the Moon are the Sun's pull and the Earth's, to one scale; the readouts give both in mm/s² and their ratio, 2.21 at the start. The dashed circle is the Earth's Hill sphere, its reach, explained below. Moon − and + step the Moon's distance to the next multiple of 25,000 km, inward or outward, and restart it on a circle at new moon, and Warp sets the days per second. On a keyboard, Z and X zoom, [ and ] warp, Space pauses, and R resets. The dots are far larger than the bodies, the orbits start as circles, and the Moon's 5.1° tilt is left out.

Download the game

Show code
// game.js: the Sun, the Earth, and the Moon, integrated live, seen from the Earth out to the Sun.
// Units: m, m/s, s. The frame does not turn, and the center of mass of the three bodies rests at
// the origin. The notebook writes the constants, the preset distance, and the warp steps into #data.
"use strict";

const DATA = JSON.parse(document.getElementById("data").textContent);
const GM = [DATA.gm_sun, DATA.gm_earth, DATA.gm_moon];   // m³/s²: the Sun, the Earth, the Moon
const {au: AU, dt: DT, r_hill: R_HILL, r_lost: R_LOST, zoom_min: HALF_MIN, zoom_max: HALF_MAX} = DATA;
const DAY = 86400;
const SUN = 0, EARTH = 1, MOON = 2;

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

// ---- the model: three bodies and Newton's gravity
function accelerations(p) {
  const a = [[0, 0], [0, 0], [0, 0]];
  for (let i = 0; i < 3; i++) {
    for (let j = i + 1; j < 3; j++) {
      const d = sub(p[j], p[i]), r3 = len(d) ** 3;
      a[i][0] += GM[j] * d[0] / r3; a[i][1] += GM[j] * d[1] / r3;   // i is pulled toward j
      a[j][0] -= GM[i] * d[0] / r3; a[j][1] -= GM[i] * d[1] / r3;   // and j toward i
    }
  }
  return a;
}

function step(s) {                         // one velocity Verlet step: half kick, drift, half kick
  for (let i = 0; i < 3; i++) {
    s.v[i][0] += 0.5 * DT * s.a[i][0]; s.v[i][1] += 0.5 * DT * s.a[i][1];
    s.p[i][0] += DT * s.v[i][0]; s.p[i][1] += DT * s.v[i][1];
  }
  s.a = accelerations(s.p);
  for (let i = 0; i < 3; i++) { s.v[i][0] += 0.5 * DT * s.a[i][0]; s.v[i][1] += 0.5 * DT * s.a[i][1]; }
  s.t += DT;
  s.steps += 1;
  if (s.lostDay === null && len(sub(s.p[MOON], s.p[EARTH])) > R_LOST) s.lostDay = s.t / DAY;
  if (s.steps % DATA.trail_every === 0) {
    s.trail.push(s.p.map((q) => [...q]));
    if (s.trail.length > DATA.trail_max) s.trail.shift();
  }
}

// The start: the Earth and Moon's center of mass on a circle around the Sun at 1 AU, the Moon at
// distance r on the Sun's side (new moon), on a circle around the Earth, going the same way round.
function start(r) {
  const [mS, mE, mM] = GM, V = Math.sqrt((mS + mE + mM) / AU), v = Math.sqrt((mE + mM) / r);
  const fE = mM / (mE + mM), fM = mE / (mE + mM);
  const p = [[0, 0], [AU + fE * r, 0], [AU - fM * r, 0]];
  const u = [[0, 0], [0, V + fE * v], [0, V - fM * v]];
  for (const q of [p, u]) {                // move to the center of mass, at rest
    const c = [0, 1].map((k) => (mS * q[0][k] + mE * q[1][k] + mM * q[2][k]) / (mS + mE + mM));
    q.forEach((x) => { x[0] -= c[0]; x[1] -= c[1]; });
  }
  return {p, v: u, a: accelerations(p), t: 0, steps: 0, lostDay: null, trail: [p.map((q) => [...q])]};
}

function tick(s, dtReal) {
  const until = s.t + Math.min(dtReal, 0.05) * DATA.warps[s.warp];   // the cap stops jumps
  while (s.t + DT <= until) step(s);
}

// ---- the state: everything that changes, in one object
function fresh() {
  return {...start(DATA.r_moon), distance: DATA.r_moon, zoom: 0, warp: DATA.warp_start, running: true};
}

function restart(s) { Object.assign(s, start(s.distance)); }

// ---- the readouts: the two pulls on the Moon
function pulls(s) {
  const toSun = len(sub(s.p[SUN], s.p[MOON])), toEarth = len(sub(s.p[EARTH], s.p[MOON]));
  return {sun: GM[SUN] / toSun ** 2, earth: GM[EARTH] / toEarth ** 2, r: toEarth};
}

// ---- the zoom: the slider runs from 0 (the Earth) to 1 (the Sun)
function view(s) {
  const half = HALF_MIN * (HALF_MAX / HALF_MIN) ** s.zoom;   // m, half the view, logarithmic in the slider
  const w = (half - HALF_MIN) / (HALF_MAX - HALF_MIN);      // where the center sits from the Earth to the Sun
  return {half, w};
}
const center = (p, w) => [p[EARTH][0] + w * (p[SUN][0] - p[EARTH][0]), p[EARTH][1] + w * (p[SUN][1] - p[EARTH][1])];

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

function layout() {
  const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight;
  document.body.className = H > W ? "upright" : H < 480 ? "wide short" : "wide";   // short: a phone held sideways
  const c = $("scene"), box = c.parentElement.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
  c.width = Math.round(box.width * dpr);
  c.height = Math.round(box.height * dpr);
  scene.ctx = c.getContext("2d");
  scene.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  Object.assign(scene, {w: box.width, h: box.height});
  render();
}

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

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

function label(ctx, text, x, y, style, align = "left") {
  ctx.font = FONT;
  ctx.textAlign = align;
  ctx.lineJoin = "round";
  ctx.lineWidth = 4;
  ctx.strokeStyle = scene.bg;              // a halo in the background color keeps text clear of the trails
  ctx.strokeText(text, x, y);
  ctx.fillStyle = style;
  ctx.fillText(text, x, y);
}

function arrow(ctx, from, dir, length, style) {   // from a point, along a unit vector, length in px
  const to = [from[0] + dir[0] * length, from[1] + dir[1] * length], a = Math.atan2(dir[1], dir[0]), tip = 8;
  line(ctx, [from, to], style, 2.5);
  ctx.beginPath();
  ctx.moveTo(to[0], to[1]);
  ctx.lineTo(to[0] - tip * Math.cos(a - 0.45), to[1] - tip * Math.sin(a - 0.45));
  ctx.lineTo(to[0] - tip * Math.cos(a + 0.45), to[1] - tip * Math.sin(a + 0.45));
  ctx.closePath();
  ctx.fillStyle = style;
  ctx.fill();
}

function scaleBar(half, px) {              // a round length that is at most a quarter of the view
  const m = 0.5 * half;
  const base = 10 ** Math.floor(Math.log10(m)), nice = [5, 2, 1].map((k) => k * base).find((x) => x <= m);
  const text = nice >= 1e9 ? `${nice / 1e9} million km` : `${(nice / 1e3).toLocaleString("en-US")} km`;
  return {length: nice * px, text};
}

function drawScene(c) {
  const {ctx, w, h} = scene, s = state, {half, w: frac} = view(s);
  const px = Math.min(w, h) / (2 * half), cx = w / 2, cy = h / 2;
  // every point is drawn relative to the view's center at its own time
  const at = (q, p) => { const o = center(p, frac); return [cx + (q[0] - o[0]) * px, cy - (q[1] - o[1]) * px]; };
  ctx.clearRect(0, 0, w, h);
  scene.bg = c.bg;

  const E = at(s.p[EARTH], s.p), M = at(s.p[MOON], s.p), S = at(s.p[SUN], s.p);
  const hill = R_HILL * px;
  if (hill > 3) {
    line(ctx, Array.from({length: 121}, (_, k) => [E[0] + hill * Math.cos(k * Math.PI / 60),
      E[1] + hill * Math.sin(k * Math.PI / 60)]), c.muted, 1.2, [5, 5]);
    if (hill > 60) label(ctx, "Hill sphere", E[0], E[1] - hill - 6, c.muted, "center");
  }
  line(ctx, s.trail.map((p) => at(p[EARTH], p)), c.second, 1.5);
  line(ctx, [...s.trail.map((p) => at(p[MOON], p)), M], c.accent, 1.5);

  const inside = (q) => q[0] > 0 && q[0] < w && q[1] > 0 && q[1] < h;
  if (inside(S)) {
    disk(ctx, S, 9, c.ink);
    label(ctx, "Sun", S[0], S[1] + 26, c.ink, "center");
  } else {                                 // the Sun out of view: a pointer at the edge
    const d = sub(S, [cx, cy]), u = [d[0] / len(d), d[1] / len(d)];
    const r = Math.min((w / 2 - 16) / Math.abs(u[0] || 1e-9), (h / 2 - 34) / Math.abs(u[1] || 1e-9));   // clear of the scale bar
    const tipAt = [cx + u[0] * r, cy + u[1] * r];
    arrow(ctx, [tipAt[0] - 24 * u[0], tipAt[1] - 24 * u[1]], u, 24, c.ink);
    label(ctx, "Sun", tipAt[0] - 40 * u[0], tipAt[1] - 40 * u[1] + 5, c.ink, "center");
  }
  disk(ctx, E, 6, c.second, c.bg);
  // the label sits down and right of the Earth, just outside the Moon's orbit while that is small on screen
  const orbit = len(sub(M, E)), off = orbit < 90 ? Math.max(orbit, 6) + 8 : 10;
  label(ctx, "Earth", E[0] + 0.6 * off, E[1] + 0.8 * off + 12, c.second);
  disk(ctx, M, 4.5, c.accent, c.bg);

  // the two pulls on the Moon, to one scale: the Sun's in ink, the Earth's in the Earth's color
  const g = pulls(s), k = 0.025 * Math.min(w, h) / 1e-3;   // px per mm/s²
  const toS = sub(s.p[SUN], s.p[MOON]), toE = sub(s.p[EARTH], s.p[MOON]);
  const unit = (d) => [d[0] / len(d), -d[1] / len(d)];      // screen y points down
  arrow(ctx, M, unit(toS), g.sun * k, c.ink);
  arrow(ctx, M, unit(toE), g.earth * k, c.second);

  const bar = scaleBar(half, px);
  line(ctx, [[12, h - 14], [12 + bar.length, h - 14]], c.muted, 2);
  label(ctx, bar.text, 18 + bar.length, h - 9, c.muted);
}

function render() {
  const c = Object.fromEntries(["bg", "ink", "accent", "second", "muted"].map((n) => [n, color(n)]));
  drawScene(c);
  const g = pulls(state), {half, w} = view(state);
  $("sun").textContent = `${(1e3 * g.sun).toFixed(2)} mm/s²`;
  $("earth").textContent = `${(1e3 * g.earth).toFixed(2)} mm/s²`;
  $("ratio").textContent = (g.sun / g.earth).toFixed(2);
  $("dist").textContent = `${(Math.round(g.r / 1e5) * 100).toLocaleString("en-US")} km`;
  $("day").textContent = (state.t / DAY).toFixed(0);
  $("status").textContent = state.lostDay === null ? "held by the Earth" : `lost after ${state.lostDay.toFixed(0)} days`;
  $("frame").textContent = w < 0.0005 ? "centered on the Earth" : w > 0.9995 ? "centered on the Sun"
    : `centered ${(100 * w).toFixed(1)} % of the way to the Sun`;
  const across = 2 * half / 1e9;
  $("width").textContent = `${across < 100 ? across.toPrecision(2) : across.toFixed(0)} million km`;
  $("zoom").value = Math.round(1000 * state.zoom);
  $("start-val").textContent = `${Math.round(state.distance / 1e3).toLocaleString("en-US")} km`;
  $("warp-val").textContent = DATA.warp_names[state.warp];
  $("go").textContent = state.running ? "Pause" : "Play";
}

// ---- controls, keys, and the loop
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
function set(partial) {
  Object.assign(state, partial);
  if ("distance" in partial) restart(state);
  render();
}
function advance(days) {                   // for stills from the console: run ahead without drawing
  const until = state.t + days * DAY;
  while (state.t + DT <= until) step(state);
  render();
}
const nudgeZoom = (sign) => set({zoom: clamp(Math.round(20 * state.zoom + sign) / 20, 0, 1)});
const nudgeWarp = (sign) => set({warp: clamp(state.warp + sign, 0, DATA.warps.length - 1)});
function nudgeMoon(sign) {                 // to the next multiple of the step, outward or inward
  const k = DATA.r_step, r = state.distance;
  const next = sign > 0 ? Math.floor(r / k + 1e-9) * k + k : Math.ceil(r / k - 1e-9) * k - k;
  set({distance: clamp(next, DATA.r_min, DATA.r_max)});
}
const go = () => set({running: !state.running});
const reset = () => set(fresh());

$("zoom").addEventListener("input", (e) => set({zoom: e.target.value / 1000}));
$("zoom-minus").addEventListener("click", () => nudgeZoom(-1));
$("zoom-plus").addEventListener("click", () => nudgeZoom(1));
$("start-minus").addEventListener("click", () => nudgeMoon(-1));
$("start-plus").addEventListener("click", () => nudgeMoon(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 = {z: () => nudgeZoom(-1), x: () => nudgeZoom(1), "[": () => nudgeWarp(-1),
    "]": () => nudgeWarp(1), " ": go, r: reset};
  const act = actions[e.key.toLowerCase()];
  if (act && !(e.target instanceof HTMLButtonElement && e.key === " ")) {
    e.preventDefault();
    act();
  }
});

const state = fresh();
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(state, dt);
  render();
  requestAnimationFrame(frame);
}

window.addEventListener("resize", layout);
new ResizeObserver(layout).observe($("scenebox"));   // the box also changes when the readouts rewrap
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render, advance};

The Sun pulls the Moon harder than the Earth

At the start the Moon sits between the Earth and the Sun, and the Sun's arrow is more than twice as long as the Earth's. It stays longer all year. Every still here comes from a Python copy of the game's three bodies, in the hidden cell below, integrated with solve_ivp, which steps Newton's equations forward with step lengths of its own choosing. method="DOP853" is a high-order method suited to smooth orbits; with rtol=1e-11 and atol=1e-3, a millimeter in the positions and a millimeter per second in the velocities, its error stays far below the last digit of any number on the page. t_eval asks for the positions on a fixed grid of times.

Show code
import json
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp

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 of the Sun (DE440) and the Moon from JPL's Astrodynamic Parameters, the Earth's from the IERS
# Conventions (2010); the Moon's mean distance from JPL's Planetary Satellite Mean Elements; the
# astronomical unit is exact by IAU definition.
GM_SUN = 1.32712440041e20      # m³/s²
GM_EARTH = 3.986004418e14      # m³/s²
GM_MOON = 4.9028e12            # m³/s²
AU = 149_597_870_700.0         # m
R_MOON = 384_400e3             # m
GM = np.array([GM_SUN, GM_EARTH, GM_MOON])
SUN, EARTH, MOON = 0, 1, 2
DAY, YEAR = 86400.0, 365.25 * 86400.0
OMEGA2 = GM_SUN / AU**3                            # 1/s², the Earth's angular speed around the Sun, squared
R_HILL = AU * (GM_EARTH / (3 * GM_SUN)) ** (1 / 3)
R_LOST = 2 * R_HILL                                # beyond this the Moon counts as lost

def setup(r, sign=+1, phase=0.0):
    """Sun, Earth, Moon as one state vector [positions, velocities], SI units, center of mass at rest.
    The Earth and Moon's center of mass circles the Sun at 1 AU; the Moon circles the Earth at r,
    starting on the Sun's side (phase 0, new moon), the same way round (sign +1) or the other (-1)."""
    V = np.sqrt(GM.sum() / AU)
    v = np.sqrt((GM_EARTH + GM_MOON) / r)
    f_e, f_m = GM_MOON / (GM_EARTH + GM_MOON), GM_EARTH / (GM_EARTH + GM_MOON)
    u = -np.array([np.cos(phase), np.sin(phase)])             # from the Earth to the Moon
    n = sign * np.array([-u[1], u[0]])                        # the Moon's direction of motion around the Earth
    pos = np.array([[0, 0], [AU, 0] - f_e * r * u, [AU, 0] + f_m * r * u])
    vel = np.array([[0, 0], [0, V] - f_e * v * n, [0, V] + f_m * v * n])
    pos, vel = pos - GM @ pos / GM.sum(), vel - GM @ vel / GM.sum()
    return np.r_[pos.ravel(), vel.ravel()]

NO_SELF = np.where(np.eye(3), np.inf, 0.0)                 # a body does not pull on itself

def accel(pos):
    """Accelerations of the three bodies; pos has shape (..., 3, 2), so runs can be stacked."""
    d = pos[..., None, :, :] - pos[..., :, None, :]           # d[..., i, j] points from body i to body j
    r3 = ((d**2).sum(-1) + NO_SELF) ** 1.5
    return (GM[:, None] * d / r3[..., None]).sum(-2)

def rhs(t, s):
    return np.r_[s[6:], accel(s[:6].reshape(3, 2)).ravel()]

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

def lost(t, s):                                    # crosses zero when the Moon passes 2 r_H from the Earth
    return np.hypot(s[4] - s[2], s[5] - s[3]) - R_LOST
lost.terminal, lost.direction = True, 1

period = 2 * np.pi * np.sqrt(R_MOON**3 / (GM_EARTH + GM_MOON))
print(f"Earth's Hill radius {R_HILL / 1e9:.4f} million km; the Moon on its circle goes round in {period / DAY:.2f} d")
Earth's Hill radius 1.4966 million km; the Moon on its circle goes round in 27.28 d
Show code
t_year = np.linspace(0, YEAR, 200_001)
year = solve_ivp(rhs, (0, YEAR), setup(R_MOON), t_eval=t_year, **TOL)
S, E, M = year.y[0:2], year.y[2:4], year.y[4:6]
g_sun = GM_SUN / np.hypot(*(M - S)) ** 2
g_earth = GM_EARTH / np.hypot(*(M - E)) ** 2
day = t_year / DAY
first = day <= 60
ratio_start = g_sun[0] / g_earth[0]

def earth_view(ax, half, label_at):
    """The game's Earth view, in million km: the Earth at the center, the Sun to the left."""
    ax.add_patch(plt.Circle((0, 0), R_HILL / 1e9, fill=False, edgecolor=MUTED, lw=1.2, ls="--"))
    ax.plot(0, 0, "o", ms=7, color=SECOND, zorder=4)
    ax.annotate("Earth", (0, 0), xytext=label_at, color=SECOND, va="center",       # clear of the Moon's paths
                arrowprops=dict(arrowstyle="-", color=SECOND, lw=0.8, shrinkA=2, shrinkB=5))
    ax.text(0, R_HILL / 1e9 + 0.04 * half, "Hill sphere", color=MUTED, ha="center", va="bottom")
    ax.set(xlim=(-half, half), ylim=(-half, half), aspect="equal", xlabel="x / million km", ylabel="y / million km")

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 4), gridspec_kw=dict(width_ratios=[1, 1.3]))
earth_view(ax1, 2.0, (0.75, 0.75))
orbit = (M - E)[:, day <= period / DAY] / 1e9
ax1.plot(*orbit, color=ACCENT, lw=1.2, alpha=0.6)
moon = orbit[:, 0]
for g, toward, color, name in [(g_sun[0], S[:, 0] - M[:, 0], INK, "Sun"), (g_earth[0], E[:, 0] - M[:, 0], SECOND, "Earth")]:
    tip = moon + 0.1 * (1e3 * g) * toward / np.hypot(*toward)        # 0.1 million km per mm/s², as in the game
    ax1.annotate("", tip, xytext=moon, arrowprops=dict(arrowstyle="-|>", color=color, lw=2, shrinkA=0, shrinkB=0))
ax1.plot(*moon, "o", ms=5, color=ACCENT, zorder=5)
ax1.text(-0.42, -0.42, "Moon", color=ACCENT, ha="right", va="top")
ax1.text(-1.9, -1.55, f"Sun's pull {1e3 * g_sun[0]:.2f} mm/s²", color=INK)
ax1.text(-1.9, -1.9, f"Earth's pull {1e3 * g_earth[0]:.2f} mm/s²", color=SECOND)
ax1.text(-1.9, 1.85, "← to the Sun", color=INK, va="top")
ax2.plot(day[first], 1e3 * g_sun[first], color=INK)
ax2.plot(day[first], 1e3 * g_earth[first], color=SECOND)
ax2.text(30, 1e3 * g_sun.max() - 0.25, "the Sun's pull", color=INK, ha="center", va="top")
ax2.text(30, 1e3 * g_earth.max() + 0.25, "the Earth's pull", color=SECOND, ha="center", va="bottom")
ax2.text(1, 4.3, f"ratio {ratio_start:.2f} at day 0", color=ACCENT, va="center")
ax2.set(xlim=(0, 60), ylim=(0, 7), xlabel="t / days", ylabel="pull on the Moon / (mm/s²)")
fig.tight_layout()
plt.show()
Left: the Earth view at the start, the Moon on the Sun side with two arrows, the Sun pull 5.96 mm/s² and the Earth pull 2.70 mm/s². Right: both pulls in mm/s² over 60 days; the Sun pull stays about 2.2 times the Earth pull.

Each pull is Newton's, the mass times the gravitational constant divided by the distance squared:

\[g_\odot = \frac{GM_\odot}{d^2}, \qquad g_\oplus = \frac{GM_\oplus}{r^2},\]

with \(\odot\) the Sun, \(\oplus\) the Earth, \(d\) the Moon's distance from the Sun, and \(r\) its distance from the Earth.

mass_ratio, distance_ratio = GM_SUN / GM_EARTH, AU / R_MOON
g_s, g_e = GM_SUN / AU**2, GM_EARTH / R_MOON**2
print(f"the Sun is {mass_ratio:,.0f} times heavier and {distance_ratio:.1f} times farther: "
      f"{mass_ratio:,.0f} / {distance_ratio:.1f}² = {mass_ratio / distance_ratio**2:.3f}")
print(f"at the mean distances: the Sun's pull {1e3 * g_s:.2f} mm/s², the Earth's {1e3 * g_e:.2f} mm/s², ratio {g_s / g_e:.2f}")
ratio = g_sun / g_earth
r_earth = np.hypot(*(M - E)) / 1e3
print(f"along the year: ratio {ratio.min():.2f} to {ratio.max():.2f}, "
      f"Earth to Moon {r_earth.min():,.0f} to {r_earth.max():,.0f} km")
print(f"at the game's start, new moon: {1e3 * g_sun[0]:.2f} / {1e3 * g_earth[0]:.2f} = {ratio[0]:.2f}")
the Sun is 332,946 times heavier and 389.2 times farther: 332,946 / 389.2² = 2.198
at the mean distances: the Sun's pull 5.93 mm/s², the Earth's 2.70 mm/s², ratio 2.20
along the year: ratio 2.07 to 2.24, Earth to Moon 374,054 to 388,059 km
at the game's start, new moon: 5.96 / 2.70 = 2.21

The Sun is 332,946 times heavier than the Earth and 389 times farther from the Moon than the Earth is, and 332,946 divided by 389 squared is 2.2. At the mean distances the Sun pulls the Moon with 5.93 mm/s² and the Earth with 2.70 mm/s². Along the integrated year the ratio runs from 2.07 to 2.24, because the Sun stretches the Moon's circular start into an oval between 374,000 and 388,000 km. At the game's start it reads 2.21: at new moon the Moon is 384,000 km closer to the Sun than the Earth is.

Two questions follow, and they have different answers. What does the harder pull do to the Moon's path around the Sun? And why does the Sun not take the Moon away from the Earth?

Free account

The second half of this tutorial needs a free account

Sign in, or create an account with your email address, and the page goes on right here. The account costs nothing and also brings the notebook download, collections, and done marks.

Still to come

  1. The loops vanish, and the path always bends toward the Sun
  2. The Earth still holds the Moon, out to half its Hill sphere
  3. How the game computes it
  4. Variations
  5. Further reading