Throw a stone into orbit on Deimos: at jogging pace it hits you in the back
Afterwards you can compute the orbital and escape speeds of a small body from its mass and radius, and its Hill sphere in a planet's tide.
- Field
- Physics
- Prerequisites
- none beyond Python basics
- Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
py-deimos-orbit.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 jupyterlabPlay: throw a stone on Deimos
Deimos, the outer moon of Mars, is a rock about 12 km across whose gravity is 1/3,900 of the Earth's. You stand on its equator under Mars and throw a stone. Drag from the astronaut's hand in the upper view and let go: the arrow sets direction and speed. Speed − and + change the last throw by 0.01 m/s and throw again, Throw repeats it, Mars on and off switches the planet's pull, Warp sets how many game seconds pass per second, and Pause and Reset do what they say. Watch the throw speed, the height, the clock, and the line that says where the stone ended up: on the ground, over your head, or lost to Mars. The game starts with one throw loaded, horizontal at 3.62 m/s, which is a jog. Press Throw, and 3.09 h later the stone hits you in the back. Every speed on the screen is relative to the ground under your feet. The astronaut and Mars in the upper view are not to scale with the lower view, which shows the whole moon.
Show code
// game.js: throw a stone from the surface of Deimos and see where it ends up.
// Units: m, m/s, s. The frame turns with Deimos's orbit around Mars, so the ground stands still
// in it: x points away from Mars, y along the orbit, and the astronaut stands under Mars at (-R, 0).
// The notebook writes the constants, the preset throw, and the warp steps into #data.
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const {mu: MU, r: R, omega: OMEGA, h0: H0, h_astro: H_ASTRO, r_hill: R_HILL, r_lost: R_LOST, dt: DT} = DATA;
const DEG = Math.PI / 180;
const UP = [-1, 0], WITH = [0, -1]; // toward Mars; along the ground with the rotation
// ---- the model: Hill's equations
function accel(r, v, tide) {
const [x, y] = r, r3 = Math.hypot(x, y) ** 3;
let ax = -MU * x / r3 + 2 * OMEGA * v[1] + OMEGA ** 2 * x; // Deimos, Coriolis, centrifugal
let ay = -MU * y / r3 - 2 * OMEGA * v[0] + OMEGA ** 2 * y;
if (tide) { ax += 2 * OMEGA ** 2 * x; ay -= OMEGA ** 2 * y; } // Mars pulls the near side harder
return [ax, ay];
}
const axpy = (a, k, b) => [a[0] + k * b[0], a[1] + k * b[1]]; // a + k b
function rk4(r, v, dt, tide) { // the classic fourth-order Runge-Kutta step
const k1r = v, k1v = accel(r, v, tide);
const k2r = axpy(v, dt / 2, k1v), k2v = accel(axpy(r, dt / 2, k1r), axpy(v, dt / 2, k1v), tide);
const k3r = axpy(v, dt / 2, k2v), k3v = accel(axpy(r, dt / 2, k2r), axpy(v, dt / 2, k2v), tide);
const k4r = axpy(v, dt, k3v), k4v = accel(axpy(r, dt, k3r), axpy(v, dt, k3v), tide);
const mix = (a, b, c, d) => [(a[0] + 2 * b[0] + 2 * c[0] + d[0]) / 6, (a[1] + 2 * b[1] + 2 * c[1] + d[1]) / 6];
return {r: axpy(r, dt, mix(k1r, k2r, k3r, k4r)), v: axpy(v, dt, mix(k1v, k2v, k3v, k4v))};
}
function starEnergy(r, v) { // J/kg relative to the stars, for Deimos alone
const vs = [v[0] - OMEGA * r[1], v[1] + OMEGA * r[0]];
return 0.5 * (vs[0] ** 2 + vs[1] ** 2) - MU / Math.hypot(r[0], r[1]);
}
const along = (r) => R * Math.atan2(-r[1], -r[0]); // m along the ground from the astronaut, + with the rotation
const height = (r) => Math.hypot(r[0], r[1]) - R;
const hours = (t) => `${(t / 3600).toFixed(2)} h`;
// ---- one step, and the three things that can end a throw
function step(s) {
const old = s.r, next = rk4(s.r, s.v, DT, s.tide);
s.r = next.r; s.v = next.v; s.t += DT;
if (old[0] < 0 && s.r[0] < 0 && s.t > 600 && Math.sign(old[1]) !== Math.sign(s.r[1])) {
const f = old[1] / (old[1] - s.r[1]); // over the astronaut: interpolate the height
const p = axpy(old, f, [s.r[0] - old[0], s.r[1] - old[1]]), h = height(p);
if (h < H_ASTRO) {
Object.assign(s, {r: p, t: s.t - (1 - f) * DT}); // stop the stone where it meets the astronaut
return end(s, "hit", `hit you in the back after ${hours(s.t)}`);
}
s.note = `passed over you ${h < 1000 ? `${h.toFixed(0)} m` : `${(h / 1000).toFixed(1)} km`} up after ${hours(s.t)}`;
}
if (height(s.r) < 0) {
const d = along(s.r), side = d >= 0 ? "with" : "against";
return end(s, "ground", `landed ${(Math.abs(d) / 1000).toFixed(1)} km from you, ${side} the rotation, after ${hours(s.t)}`);
}
if (Math.hypot(s.r[0], s.r[1]) > R_LOST && (s.tide || starEnergy(s.r, s.v) >= 0)) {
return end(s, "lost", s.tide ? `lost to Mars after ${hours(s.t)}` : `gone for good after ${hours(s.t)}`);
}
if (s.t >= s.nextDot) { s.trail.push(s.r); s.nextDot += DATA.trail_every; }
}
function end(s, outcome, text) {
s.trail.push(s.r);
Object.assign(s, {flying: false, outcome, note: text});
}
function tick(s, dtReal) {
const until = s.t + Math.min(dtReal, 0.05) * DATA.warps[s.warp]; // the cap stops jumps
while (s.flying && s.t < until) step(s);
}
// ---- the state: everything that changes, in one object
function fresh() {
return {speed: DATA.preset.speed, angle: DATA.preset.angle, tide: true, warp: DATA.warp_start,
running: true, flying: false, thrown: false, outcome: null, note: "press Throw",
t: 0, r: release(), v: [0, 0], trail: [], ghost: null, nextDot: 0, aim: null, zoom: DATA.zoom_min};
}
function release() { return [UP[0] * (R + H0), UP[1] * (R + H0)]; }
function throwStone(s = state) {
const a = s.angle * DEG, d = [Math.cos(a) * WITH[0] + Math.sin(a) * UP[0], Math.cos(a) * WITH[1] + Math.sin(a) * UP[1]];
const ghost = s.trail.length ? {pts: s.trail, tide: s.lastTide} : s.ghost;
Object.assign(s, {t: 0, r: release(), v: [s.speed * d[0], s.speed * d[1]], trail: [release()], ghost,
flying: true, thrown: true, outcome: null, note: "in flight", nextDot: DATA.trail_every, lastTide: s.tide});
render();
}
function advance(seconds) { // for stills from the console: fly ahead without drawing
const until = state.t + seconds;
while (state.flying && state.t < until) step(state);
render();
}
// ---- drawing
const $ = (id) => document.getElementById(id);
const color = (name) => getComputedStyle(document.documentElement).getPropertyValue(`--${name}`).trim();
const FONT = "14px system-ui, sans-serif";
const views = {near: {id: "near"}, far: {id: "far"}};
function fit(v) { // size a canvas to its box, sharp on any screen
const c = $(v.id), box = c.parentElement.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
c.width = Math.round(box.width * dpr);
c.height = Math.round(box.height * dpr);
v.ctx = c.getContext("2d");
v.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
Object.assign(v, {w: box.width, h: box.height});
}
function layout() {
const W = document.documentElement.clientWidth, H = document.documentElement.clientHeight;
document.body.className = H > W ? "upright" : "wide";
fit(views.near);
fit(views.far);
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);
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); }
}
function label(ctx, text, x, y, style, align = "left") {
ctx.font = FONT;
ctx.textAlign = align;
ctx.fillStyle = style;
ctx.fillText(text, x, y);
}
function arrow(ctx, [x0, y0], [x1, y1], style, width = 2) {
const a = Math.atan2(y1 - y0, x1 - x0), tip = 9;
line(ctx, [[x0, y0], [x1, y1]], style, width);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x1 - tip * Math.cos(a - 0.4), y1 - tip * Math.sin(a - 0.4));
ctx.lineTo(x1 - tip * Math.cos(a + 0.4), y1 - tip * Math.sin(a + 0.4));
ctx.closePath();
ctx.fillStyle = style;
ctx.fill();
}
// The near view: the astronaut on the ground under Mars, about 16 m across. Mars and the bend of the
// ground are drawn larger than they are, so that both fit; the stone is to scale with the astronaut.
function nearGeometry(v) {
const k = Math.min(v.w / 16, v.h / 9); // px per m
// the feet high enough that the ground's edges stay above the two labels at the bottom
const foot = [v.w / 2, Math.min(0.82 * v.h, v.h - 28 - v.w / 17.6)];
return {k, foot, hand: [foot[0], foot[1] - H0 * k], speedPx: 0.4 * Math.min(v.w, v.h) / DATA.speed_max};
}
function drawNear(v, c) {
const {ctx, w, h} = v, {k, foot, hand, speedPx} = nearGeometry(v);
ctx.clearRect(0, 0, w, h);
// Mars overhead, as large as the 16.6° it spans from Deimos would look with the head at the bottom edge
const mr = 0.5 * h * Math.tan(DATA.mars_deg / 2 * DEG) * 2.2, mars = [w / 2, 4 + mr];
disk(ctx, mars, mr, state.tide ? c.mars : c.globe);
label(ctx, state.tide ? "Mars" : "Mars off", mars[0], mars[1] + 5, state.tide ? c.ink : c.muted, "center");
// the ground, bent more than it is
const bend = 2.2 * w;
ctx.beginPath();
ctx.arc(foot[0], foot[1] + bend, bend, 0, 2 * Math.PI);
ctx.fillStyle = c.globe;
ctx.fill();
ctx.strokeStyle = c.ink;
ctx.lineWidth = 1.5;
ctx.stroke();
label(ctx, "rotation →", w - 8, h - 10, c.muted, "right");
label(ctx, "drag from the hand to throw", 8, h - 10, c.muted); // on the ground, clear of Mars
// the astronaut: 2 m tall, a stick figure facing the way Deimos turns
const head = [foot[0], foot[1] - 1.75 * k];
line(ctx, [[foot[0] - 0.25 * k, foot[1]], [foot[0], foot[1] - 0.9 * k], [foot[0] + 0.25 * k, foot[1]]], c.ink, 3);
line(ctx, [[foot[0], foot[1] - 0.9 * k], [foot[0], foot[1] - 1.55 * k]], c.ink, 3);
line(ctx, [[foot[0] - 0.35 * k, foot[1] - 1.1 * k], [foot[0], foot[1] - 1.4 * k], hand], c.ink, 3);
disk(ctx, head, 0.22 * k, c.bg, c.ink);
// the throw: the aim while dragging, else the loaded throw
const a = (state.aim ? state.aim.angle : state.angle) * DEG, sp = state.aim ? state.aim.speed : state.speed;
if (!state.flying || state.aim) {
const tip = [hand[0] + sp * speedPx * Math.cos(a), hand[1] - sp * speedPx * Math.sin(a)];
arrow(ctx, hand, tip, c.accent, 3);
const text = `${sp.toFixed(2)} m/s`;
if (Math.sin(a) > 0.7) { // steep: beside the shaft, so the number stays clear of Mars
label(ctx, text, Math.max(hand[0], tip[0]) + 8, (hand[1] + tip[1]) / 2, c.accent);
} else {
label(ctx, text, tip[0] + (Math.cos(a) >= 0 ? 6 : -6), tip[1] - 6, c.accent, Math.cos(a) >= 0 ? "left" : "right");
}
}
// the stone, when it is within the view
if (state.thrown) {
const s = along(state.r), z = height(state.r);
const p = [foot[0] + s * k, foot[1] - z * k];
if (p[0] > -10 && p[0] < w + 10 && p[1] > -10) disk(ctx, p, 5, c.accent, c.bg);
}
}
// The far view: Deimos to scale, the Hill sphere, and the stone's path
function drawFar(v, c) {
const {ctx, w, h} = v, k = Math.min(w, h - 48) / (2 * state.zoom), cx = w / 2, cy = h / 2; // 24 px for the keys
const at = (p) => [cx - p[1] * k, cy + p[0] * k]; // screen right: with the rotation; up: toward Mars
ctx.clearRect(0, 0, w, h);
ctx.beginPath();
ctx.arc(cx, cy, R_HILL * k, 0, 2 * Math.PI);
ctx.strokeStyle = c.muted;
ctx.lineWidth = 1.2;
ctx.setLineDash([5, 5]);
ctx.stroke();
ctx.setLineDash([]);
disk(ctx, [cx, cy], R * k, c.globe, c.ink);
// the keys sit in the corners, clear of the circle at any zoom: Mars up at the top left,
// the scale bar at the bottom left, the Hill sphere's dashes at the bottom right
arrow(ctx, [14, 40], [14, 4], state.tide ? c.ink : c.muted, 1.5);
label(ctx, state.tide ? "to Mars" : "to Mars (off)", 22, 18, state.tide ? c.ink : c.muted);
const hill = `Hill sphere, ${(R_HILL / 1000).toFixed(1)} km`;
ctx.font = FONT;
const hx = w - 8 - ctx.measureText(hill).width;
line(ctx, [[hx - 26, h - 14], [hx - 4, h - 14]], c.muted, 1.2, [5, 5]);
label(ctx, hill, w - 8, h - 9, c.muted, "right");
if (state.ghost) {
ctx.globalAlpha = 0.35;
line(ctx, state.ghost.pts.map(at), state.ghost.tide ? c.accent : c.second, 1.5);
ctx.globalAlpha = 1;
}
if (state.thrown) {
const col = state.lastTide ? c.accent : c.second;
line(ctx, [...state.trail, state.r].map(at), col, 2);
disk(ctx, at(state.r), 5, col, c.bg);
}
disk(ctx, at(release()), 3, c.ink);
line(ctx, [[8, h - 14], [8 + 10000 * k, h - 14]], c.muted, 2);
label(ctx, "10 km", 12 + 10000 * k, h - 9, c.muted);
}
function zoomTarget() { // half the far view: 15 % beyond the farthest point flown
let far = 0;
for (const p of state.trail) far = Math.max(far, Math.hypot(p[0], p[1]));
far = Math.max(far, Math.hypot(state.r[0], state.r[1]));
return Math.min(DATA.zoom_max, Math.max(DATA.zoom_min, 1.15 * far));
}
function render() {
const c = Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted", "globe", "mars"].map((n) => [n, color(n)]));
drawNear(views.near, c);
drawFar(views.far, c);
const sp = state.aim ? state.aim.speed : state.speed, an = state.aim ? state.aim.angle : state.angle;
$("speed").textContent = `${sp.toFixed(2)} m/s`;
$("angle").textContent = `${an.toFixed(0)}°`;
$("height").textContent = `${(Math.max(0, height(state.r)) / 1000).toFixed(2)} km`;
$("clock").textContent = hours(state.t);
$("note").textContent = state.note;
$("mars").textContent = state.tide ? "Mars on" : "Mars off";
$("go").textContent = state.running ? "Pause" : "Play";
$("warp-val").textContent = DATA.warp_names[state.warp];
}
// ---- controls, keys, and the loop
function set(partial) {
Object.assign(state, partial);
render();
}
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
const nudgeSpeed = (sign) => { set({speed: clamp(state.speed + sign * DATA.speed_step, 0, DATA.speed_max)}); throwStone(); };
const nudgeWarp = (sign) => set({warp: clamp(state.warp + sign, 0, DATA.warps.length - 1)});
const toggleMars = () => set({tide: !state.tide});
const go = () => set({running: !state.running});
const reset = () => set(fresh());
function aimAt(e) { // the drag from the hand sets speed and direction
const v = views.near, box = $("near").getBoundingClientRect(), {hand, speedPx} = nearGeometry(v);
const dx = e.clientX - box.left - hand[0], dy = hand[1] - (e.clientY - box.top);
const angle = dy > 0 ? Math.atan2(dy, dx) / DEG : (dx >= 0 ? 0 : 180); // only the upper half
const speed = clamp(Math.round(100 * Math.hypot(dx, Math.max(dy, 0)) / speedPx) / 100, 0, DATA.speed_max);
state.aim = {speed, angle: Math.round(angle)};
render();
}
$("near").addEventListener("pointerdown", (e) => { $("near").setPointerCapture(e.pointerId); aimAt(e); });
$("near").addEventListener("pointermove", (e) => { if (state.aim) aimAt(e); });
$("near").addEventListener("pointerup", () => {
if (!state.aim) return;
Object.assign(state, {speed: state.aim.speed, angle: state.aim.angle, aim: null});
throwStone();
});
$("throw").addEventListener("click", () => throwStone());
$("speed-minus").addEventListener("click", () => nudgeSpeed(-1));
$("speed-plus").addEventListener("click", () => nudgeSpeed(1));
$("mars").addEventListener("click", toggleMars);
$("go").addEventListener("click", go);
$("warp-minus").addEventListener("click", () => nudgeWarp(-1));
$("warp-plus").addEventListener("click", () => nudgeWarp(1));
$("reset").addEventListener("click", reset);
document.addEventListener("keydown", (e) => {
const actions = {t: () => throwStone(), " ": go, "-": () => nudgeSpeed(-1), "=": () => nudgeSpeed(1),
"+": () => nudgeSpeed(1), m: toggleMars, "[": () => nudgeWarp(-1), "]": () => nudgeWarp(1), 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);
state.zoom += (zoomTarget() - state.zoom) * Math.min(1, 3 * dt); // ease toward the new size in about 1 s
render();
requestAnimationFrame(frame);
}
window.addEventListener("resize", layout);
new ResizeObserver(layout).observe($("farbox")); // the boxes also change when the readouts rewrap
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render, throwStone, advance};
A jogging pace is an orbit
Thrown flat at 3.62 m/s, the stone skims around the moon and comes back from behind at shoulder height. Every still here comes from a Python copy of the game's model, hidden in the next cell and integrated with solve_ivp. Right in the far view is along the orbit, the way the ground turns. The height curve adds a throw 8 cm/s faster.
Show code
import json
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from scipy.integrate import solve_ivp
from scipy.optimize import brentq
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"
# Deimos and Mars, JPL Solar System Dynamics (ssd.jpl.nasa.gov, read 2026-10-18):
# "Planetary Satellite Physical Parameters" (MAR097) and "Planetary Satellite Mean Elements" (MAR099).
MU = 96200.0 # m³/s², GM of Deimos
R = 6200.0 # m, mean radius
GM_MARS = 42828.37362e9 # m³/s²
A = 23457e3 # m, radius of Deimos's orbit
P = 1.2625 * 86400 # s, orbital period, and the rotation period (tidally locked)
R_MARS = 3389.5e3 # m, mean radius of Mars, for the drawing only
OMEGA = 2 * np.pi / P # rad/s
G = 6.6743e-11 # m³/(kg s²)
H0, H_ASTRO = 1.5, 2.0 # m: the hand, and the top of the astronaut's head
R_HILL = (MU / (3 * OMEGA**2)) ** (1 / 3)
R_LOST = 3 * R_HILL # beyond this a stone counts as lost
DAY, DT = 86400.0, 5.0 # s; DT is the game's time step
# The frame turns with Deimos's orbit, so the ground stands still in it. x points away from Mars,
# y along the orbit. The astronaut stands at the point under Mars, (-R, 0).
UP, WITH = np.array([-1.0, 0.0]), np.array([0.0, -1.0]) # toward Mars; along the ground with the rotation
def accel(r, v, tide=True):
"""Hill's equations: Deimos, Coriolis, centrifugal, and Mars's tide if tide is on. SI units."""
x, y = r
ax = -MU * x / np.hypot(x, y) ** 3 + 2 * OMEGA * v[1] + OMEGA**2 * x
ay = -MU * y / np.hypot(x, y) ** 3 - 2 * OMEGA * v[0] + OMEGA**2 * y
if tide:
ax, ay = ax + 2 * OMEGA**2 * x, ay - OMEGA**2 * y
return np.array([ax, ay])
def rhs(t, s, tide):
return np.r_[s[2:], accel(s[:2], s[2:], tide)]
def start(speed, angle):
"""Release state, 1.5 m up; angle 0 is along the ground with the rotation, 90 up, 180 against."""
a = np.radians(angle)
return np.r_[(R + H0) * UP, speed * (np.cos(a) * WITH + np.sin(a) * UP)]
def star_energy(s):
"""Energy per kg relative to the stars, for Deimos alone, in J/kg."""
v_star = s[2:] + OMEGA * np.array([-s[1], s[0]]) # add the ground's own motion
return 0.5 * v_star @ v_star - MU / np.hypot(*s[:2])
def ground(t, s, tide):
return np.hypot(*s[:2]) - R
ground.terminal, ground.direction = True, -1
def lost(t, s, tide):
if not tide and star_energy(s) < 0: # Deimos alone keeps a bound stone, however far it climbs
return -1.0
return np.hypot(*s[:2]) - R_LOST
lost.terminal, lost.direction = True, 1
def over(t, s, tide): # zero on the line from the center through the astronaut
return s[1]
TOL = dict(method="DOP853", rtol=1e-10, atol=1e-6)
def fly(speed, angle, tide=True, t_max=3 * DAY, dense=False, stop_far=True):
events = [ground, lost, over] if stop_far else [ground, over]
return solve_ivp(rhs, (0, t_max), start(speed, angle), args=(tide,), events=events,
dense_output=dense, **TOL)
def outcome(sol):
"""('ground', t, distance along the ground in m, + with the rotation), ('lost', t), or ('flying', t)."""
if sol.t_events[0].size:
x, y = sol.y_events[0][0][:2]
return "ground", sol.t_events[0][0], R * np.arctan2(-y, -x)
if sol.t_events[1].size and sol.status == 1:
return "lost", sol.t_events[1][0], None
return "flying", sol.t[-1], None
def first_pass(speed, angle, tide=True):
"""Height over the astronaut and time of the first pass, or None if the stone lands first."""
sol = fly(speed, angle, tide, t_max=12 * 3600)
for t, s in zip(sol.t_events[2], sol.y_events[2]):
if s[0] < 0 and t > 600: # on Mars's side, after leaving the hand
return np.hypot(*s[:2]) - R, t
return None
def path(sol, n=2000):
"""Points of a run for the far view, in km: right is with the rotation, up is toward Mars."""
t = np.linspace(0, sol.t[-1], n)
x, y = sol.sol(t)[:2]
return -y / 1e3, -x / 1e3, t
def far_view(ax, extent, tide=True):
"""The game's lower view: Deimos to scale, the Hill sphere dashed, Mars up."""
ax.add_patch(plt.Circle((0, 0), R / 1e3, facecolor="#ebe9e4", edgecolor=INK, lw=1.2, zorder=2))
ax.add_patch(plt.Circle((0, 0), R_HILL / 1e3, fill=False, edgecolor=MUTED, lw=1.2, ls="--", zorder=1))
ax.annotate("", (0, extent), xytext=(0, 0.8 * extent), zorder=3,
arrowprops=dict(arrowstyle="-|>", color=INK if tide else MUTED, lw=1.2))
ax.text(0.04 * extent, 0.9 * extent, "to Mars" if tide else "to Mars (off)", color=INK if tide else MUTED, va="center")
ax.plot(0, (R + H0) / 1e3, "o", ms=4, color=INK, zorder=5)
ax.set(xlim=(-extent, extent), ylim=(-extent, extent), aspect="equal", xlabel="along the orbit / km",
ylabel="toward Mars / km")
print(f"Deimos: GM = {MU:,.0f} m³/s², M = GM/G = {MU / G:.3g} kg, R = {R / 1e3:.1f} km; "
f"one turn in {P / 3600:.2f} h")
Deimos: GM = 96,200 m³/s², M = GM/G = 1.44e+15 kg, R = 6.2 km; one turn in 30.30 h
Show code
def miss(speed, angle):
"""Height of the first pass over the astronaut minus the hand's height; a landing counts as far below."""
p = first_pass(speed, angle)
return (p[0] if p else -H0 - 1.0) - H0
v_hit = {0: brentq(miss, 3.62, 3.63, args=(0,), xtol=1e-7),
180: brentq(miss, 4.31, 4.33, args=(180,), xtol=1e-7)}
hit = fly(v_hit[0], 0, t_max=first_pass(v_hit[0], 0)[1], dense=True)
fast = fly(3.70, 0, t_max=first_pass(3.70, 0)[1], dense=True)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 4), gridspec_kw=dict(width_ratios=[1, 1.3]))
far_view(ax1, 10)
x, y, t = path(hit)
ax1.plot(x, y, color=ACCENT, zorder=4)
ax1.text(-9.5, -9.3, f"thrown flat at {v_hit[0]:.2f} m/s", color=ACCENT)
for sol, speed, alpha, xy in [(hit, v_hit[0], 1.0, (3.4, 14)), (fast, 3.70, 0.45, (2.95, 230))]:
t = np.linspace(0, sol.t[-1], 3000)
h = np.hypot(*sol.sol(t)[:2]) - R
ax2.plot(t / 3600, h, color=ACCENT, alpha=alpha)
ax2.text(*xy, f"{speed:.2f} m/s: back\n{h[-1]:.1f} m up", color=ACCENT,
alpha=max(alpha, 0.7), va="bottom", ha="left")
ax2.axhline(H_ASTRO, color=MUTED, lw=1, ls="--")
ax2.text(1.55, -12, "top of the head, 2 m", color=MUTED, ha="center", va="top")
ax2.set(xlim=(0, 5), ylim=(-45, 580), xlabel="t / h", ylabel="height / m")
fig.tight_layout()
plt.show()
The stone keeps falling toward Deimos and keeps missing it, which is all an orbit is. On a circle at the surface, gravity supplies exactly the centripetal acceleration, \(v^2/R = GM/R^2\), so the circular speed and the time for one lap are
with \(GM\) the moon's mass times the gravitational constant and \(R\) its radius. Deimos is about 15 × 12 × 11 km; the model is a sphere of its mean radius, 6.2 km.
g = MU / R**2
v_c, T_c = np.sqrt(MU / R), 2 * np.pi * np.sqrt(R**3 / MU)
u = OMEGA * (R + H0) # the ground's own speed relative to the stars
print(f"surface gravity {1e3 * g:.2f} mm/s², 1/{9.80665 / g:,.0f} of the Earth's")
print(f"circular speed v_c = {v_c:.3f} m/s, one lap in {T_c / 3600:.2f} h; the ground moves at {u:.3f} m/s")
v_circ = np.sqrt(MU / (R + H0)) # the circle through the hand, Deimos alone
for angle, name, sign in [(0, "with ", 1), (180, "against", -1)]:
h, t = first_pass(v_hit[angle], angle)
v_off = v_circ - sign * u # that circle as a ground speed
t_off = first_pass(v_off + 1e-6, angle, tide=False)[1]
print(f"hit, {name} the rotation: Mars off {v_off:.2f} m/s, back after {t_off / 3600:.2f} h; "
f"Mars on {v_hit[angle]:.4f} m/s on the ground, {v_hit[angle] + sign * u:.3f} m/s relative to the stars, "
f"back after {t / 3600:.2f} h")
t = np.linspace(0, hit.t[-1], 3000)
x, y = hit.sol(t)[:2]
h = np.hypot(x, y) - R
top = [i for i in range(1, t.size - 1) if h[i - 1] < h[i] > h[i + 1]]
print(f"the {v_hit[0]:.2f} m/s throw: humps of {h[top].max():.0f} m at "
+ " and ".join(f"{np.degrees(np.arctan2(y[i], -x[i])):+.0f}°" for i in top) + " from the point under Mars")
p = first_pass(v_hit[0] + 0.01, 0)
kind, t_end, d = outcome(fly(v_hit[0] - 0.001, 0))
print(f"1 cm/s faster: passes {p[0]:.1f} m up; 1 mm/s slower: {kind} after {t_end / 3600:.2f} h, "
f"{d / 1e3:.1f} km along the ground")
surface gravity 2.50 mm/s², 1/3,919 of the Earth's circular speed v_c = 3.939 m/s, one lap in 2.75 h; the ground moves at 0.357 m/s hit, with the rotation: Mars off 3.58 m/s, back after 3.02 h; Mars on 3.6239 m/s on the ground, 3.981 m/s relative to the stars, back after 3.09 h hit, against the rotation: Mars off 4.30 m/s, back after 2.52 h; Mars on 4.3229 m/s on the ground, 3.966 m/s relative to the stars, back after 2.55 h the 3.62 m/s throw: humps of 148 m at -90° and +90° from the point under Mars 1 cm/s faster: passes 5.9 m up; 1 mm/s slower: ground after 1.46 h, 18.3 km along the ground
The circular speed is 3.94 m/s and a lap takes 2.75 h. That speed is relative to the stars, and the ground moves too: Deimos turns once per orbit around Mars, 30.30 h, so its surface carries you at 0.357 m/s. With Mars off, the throw that hits you is the circle itself, 3.58 m/s on the ground, and it catches up with you after 3.02 h, since you moved on during the lap.
Mars's tide bends the circle into an oval, lowest on the line to Mars and 148 m up a quarter of the way round: the two humps in the height curve. The oval is fastest at its low point, your hand, so the throw takes 3.62 m/s, 3.98 relative to the stars and 1 % over \(v_c\), and is back 4 min later. Against the rotation the ground carries you toward the stone: 4.32 m/s, back after 2.55 h.
The hit is a knife edge. At 1 mm/s slower the stone lands on the far side of the moon; at 1 cm/s faster it passes over you 5.9 m up, 4 m above your head. brentq (root finding with scipy.optimize) finds the preset as the speed at which the pass over the astronaut is 1.5 m high.
A little faster, and the stone is gone
Switch Mars off and throw straight up. At 4.5 m/s the stone climbs 12 km and lands 4.3 km behind you after 4.4 h; at 5.0 m/s it climbs 26 km and is back after 11 h; at 5.6 m/s it never returns. With Mars off the game keeps Deimos alone, but the ground still turns, so the paths still bend sideways and the stone comes down behind you, against the rotation (the Coriolis force of the turning frame).
Show code
up_speeds = [4.5, 5.0, 5.4, 5.6]
off = {v: fly(v, 90, tide=False, t_max=4 * DAY, dense=True, stop_far=False) for v in up_speeds}
shade = dict(zip(up_speeds, [0.4, 0.6, 0.8, 1.0]))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 4), gridspec_kw=dict(width_ratios=[1, 1.3]))
far_view(ax1, 40, tide=False)
for v in [4.5, 5.0, 5.6]:
x, y, t = path(off[v], 4000)
ax1.plot(x, y, color=SECOND, alpha=shade[v], zorder=4)
for v in up_speeds:
t = np.linspace(0, off[v].t[-1], 4000)
h = (np.hypot(*off[v].sol(t)[:2]) - R) / 1e3
ax2.plot(t / 3600, h, color=SECOND, alpha=shade[v])
end = f"back after {t[-1] / 3600:.1f} h" if off[v].status == 1 else "gone"
xy = {4.5: (1, -3), 5.0: (13, 22), 5.4: (36, 110), 5.6: (22, 195)}[v] # label positions, h and km
ax2.text(*xy, f"{v} m/s, {end}", color=SECOND, alpha=max(shade[v], 0.6), ha="left",
va="top" if v == 4.5 else "bottom")
ax1.text(-38, -37, "4.5, 5.0, 5.6 m/s, straight up", color=SECOND)
ax2.set(xlim=(0, 96), ylim=(-17, 210), yticks=range(0, 201, 50), xlabel="t / h", ylabel="height / km")
fig.tight_layout()
plt.show()
With Deimos alone, the energy per kilogram relative to the stars, \(\tfrac12 v^2 - GM/r\), does not change along the path. A stone with negative energy is bound and comes back however high it climbs; at zero or more it reaches any distance. The escape speed is the speed at which the energy at the surface is zero:
v_esc = np.sqrt(2 * MU / (R + H0))
print(f"v_esc = {np.sqrt(2 * MU / R):.3f} m/s = sqrt(2) v_c = {np.sqrt(2) * v_c:.3f} m/s, relative to the stars")
def esc_ground(angle):
"""The ground speed in this direction that is v_esc relative to the stars."""
c = np.cos(np.radians(angle))
return -u * c + np.sqrt(v_esc**2 - u**2 * (1 - c**2))
for angle, name in [(0, "with the rotation"), (90, "straight up"), (180, "against the rotation")]:
print(f" as a ground speed, {name:21s}: {esc_ground(angle):.2f} m/s")
print(f" straight up, the ground's sideways {u:.3f} m/s saves {v_esc - esc_ground(90):.3f} m/s")
print("Mars off, straight up:")
for v in up_speeds:
s0 = start(v, 90)
sol = off[v]
top = (np.hypot(*sol.y[:2]).max() - R) / 1e3
kind, t_end, d = outcome(sol)
where = (f"lands after {t_end / 3600:5.1f} h, {abs(d) / 1e3:.1f} km "
f"{'with' if d > 0 else 'against'} the rotation" if kind == "ground" else f"not back within {t_end / 3600:.0f} h")
print(f" {v} m/s: {np.linalg.norm(s0[2:] + u * WITH):.2f} m/s relative to the stars, "
f"energy {star_energy(s0):+.2f} J/kg, top {top:6.1f} km, {where}")
v_esc = 5.571 m/s = sqrt(2) v_c = 5.571 m/s, relative to the stars as a ground speed, with the rotation : 5.21 m/s as a ground speed, straight up : 5.56 m/s as a ground speed, against the rotation : 5.93 m/s straight up, the ground's sideways 0.357 m/s saves 0.011 m/s Mars off, straight up: 4.5 m/s: 4.51 m/s relative to the stars, energy -5.32 J/kg, top 11.8 km, lands after 4.4 h, 4.3 km against the rotation 5.0 m/s: 5.01 m/s relative to the stars, energy -2.95 J/kg, top 26.4 km, lands after 11.3 h, 13.1 km against the rotation 5.4 m/s: 5.41 m/s relative to the stars, energy -0.87 J/kg, top 104.5 km, lands after 72.9 h, 14.3 km against the rotation 5.6 m/s: 5.61 m/s relative to the stars, energy +0.23 J/kg, top 429.3 km, not back within 96 h
That gives 5.57 m/s, and on the screen it is a different number in each direction. Thrown straight up, the ground's 0.357 m/s adds sideways and saves only 0.011 m/s, so 5.56 m/s on the screen is the escape speed. Thrown along the ground, the ground's speed adds or subtracts in full: you need 5.21 m/s with the rotation and 5.93 against it. At 5.4 m/s straight up the energy is still negative by a little, and the stone climbs 105 km, far out of the lower view, and drops back after three days.
Mars steals stones that rise too high
With Mars on, throw straight up at 4.5 m/s, 81 % of the escape speed. The stone rises toward Mars, crosses the dashed circle at 21 km, and is gone after 10.4 h.
Show code
on = fly(4.5, 90, tide=True, dense=True)
phi = lambda x: -MU / np.abs(x) - 1.5 * OMEGA**2 * x**2 # the effective potential on the line to Mars, J/kg
x_hand = -(R + H0)
v_pass = np.sqrt(2 * (phi(R_HILL) - phi(x_hand)))
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 4), gridspec_kw=dict(width_ratios=[1, 1.3]))
far_view(ax1, 30)
ax1.text(0.8 * R_HILL / 1e3, -0.85 * R_HILL / 1e3, f"{R_HILL / 1e3:.1f} km", color=MUTED, va="top")
for sol, color, name in [(on, ACCENT, "Mars on"), (off[4.5], SECOND, "Mars off")]:
x, y, t = path(sol, 3000)
ax1.plot(x, y, color=color, zorder=4)
ax1.text(-28, -24, "Mars on", color=ACCENT)
ax1.text(-28, -28, "Mars off", color=SECOND)
d = np.r_[np.linspace(-40e3, -R, 400), np.nan, np.linspace(R, 40e3, 400)] # distance toward Mars, outside the rock
ax2.plot(d / 1e3, phi(-d), color=INK)
ax2.axvspan(-R / 1e3, R / 1e3, color="#ebe9e4", lw=0)
ax2.text(0, -5, "Deimos", color=INK, ha="center")
ax2.plot([R_HILL / 1e3, -R_HILL / 1e3, -x_hand / 1e3], [phi(R_HILL), phi(R_HILL), phi(x_hand)], "o", ms=6, color=INK)
ax2.axhline(phi(R_HILL), color=MUTED, lw=1, ls="--")
ax2.annotate("", (4.6, phi(R_HILL)), xytext=(4.6, phi(x_hand)),
arrowprops=dict(arrowstyle="<->", color=MUTED, lw=1.2, shrinkA=0, shrinkB=0))
ax2.text(3.8, (phi(R_HILL) + phi(x_hand)) / 2, f"{v_pass:.2f} m/s\nto climb", color=INK, va="center", ha="right")
ax2.text(R_HILL / 1e3, phi(R_HILL) + 0.4, "pass", color=INK, ha="center", va="bottom")
ax2.text(-R_HILL / 1e3, phi(R_HILL) + 0.4, "twin pass", color=INK, ha="center", va="bottom")
ax2.text(-x_hand / 1e3 + 1, phi(x_hand) - 0.2, "hand", color=INK, va="top")
ax2.set(xlim=(-40, 40), ylim=(-17, -4), xlabel="distance toward Mars / km", ylabel=r"$\Phi_\mathrm{eff}$ / (J/kg)")
fig.tight_layout()
plt.show()
Mars pulls harder on the side of Deimos that faces it than on the moon's center, and that difference is the tide. Along the line to Mars it pushes outward with \(2GM_\text{Mars}\,x/a^3 = 2\Omega^2 x\), where \(x\) is the distance from Deimos's center, \(a\) the radius of the orbit, and \(\Omega\) its angle per second, since \(\Omega^2 = GM_\text{Mars}/a^3\). The centrifugal force of the turning frame adds \(\Omega^2 x\), for \(3\Omega^2 x\) in all, and Deimos pulls back with \(GM/x^2\). The two balance at the Hill radius,
with \(m\) and \(M\) the masses of Deimos and Mars. Inside \(r_H\) Deimos wins, outside Mars does.
Getting there costs speed. The Coriolis force acts sideways to the motion and does no work, so \(\tfrac12 v^2\), with \(v\) relative to the ground, plus an effective potential stays fixed:
This is Deimos's valley tilted by Mars. Along the line to Mars it rises to a pass at \(r_H\), the Lagrange point L1, with a twin as high on the far side. A stone whose \(\tfrac12 v^2\) at the hand is smaller than the climb to the pass cannot get over, in any direction: below 4.23 m/s, nothing leaves. The map below marks where each throw ends within three days, by speed and direction (0° flat with the rotation, 90° straight up): gray on the ground, red lost to Mars, a square still flying, and the two dots hit you.
Show code
angles = np.arange(0, 181, 3)
speeds = np.round(np.arange(2.0, 6.5001, 0.05), 2)
fate = np.array([[outcome(fly(v, a))[0][0] for v in speeds] for a in angles]) # 'g', 'l', or 'f'
colors = {"g": (*plt.matplotlib.colors.to_rgb(MUTED), 0.35), "l": (*plt.matplotlib.colors.to_rgb(ACCENT), 0.35),
"f": (1, 1, 1, 0)}
image = np.array([[colors[c] for c in row] for row in fate])
fig, ax = plt.subplots()
ax.imshow(image, origin="lower", aspect="auto", interpolation="nearest",
extent=(speeds[0] - 0.025, speeds[-1] + 0.025, angles[0] - 1.5, angles[-1] + 1.5))
fy, fx = np.nonzero(fate == "f")
ax.plot(speeds[fx], angles[fy], "s", ms=2.5, color=INK)
ax.axvline(v_pass, color=MUTED, lw=1, ls="--")
ax.text(v_pass - 0.05, 186, f"pass at $r_H$, {v_pass:.2f} m/s", color=MUTED, ha="right", va="bottom")
a_fine = np.linspace(0, 180, 181)
ax.plot(esc_ground(a_fine), a_fine, color=SECOND, lw=1.2, ls="--")
ax.text(esc_ground(180) + 0.05, 186, "escape, Deimos alone", color=SECOND, ha="right", va="bottom")
ax.plot([v_hit[0], v_hit[180]], [0, 180], "o", ms=6, color=INK, zorder=5)
ax.text(v_hit[0], 6, "hits you", color=INK, ha="center", va="bottom")
ax.text(v_hit[180], 174, "hits you", color=INK, ha="center", va="top")
ax.text(2.1, 100, "back on the ground", color=INK)
ax.text(4.55, 60, "lost to Mars", color=ACCENT)
ax.annotate("still flying\nafter 3 days", (4.08, 2.5), xytext=(3.7, 45), color=INK, ha="center",
arrowprops=dict(arrowstyle="-", color=INK, lw=0.8, shrinkA=2, shrinkB=3))
ax.set(xlim=(2, 6.5), ylim=(-1.5, 181.5), yticks=range(0, 181, 30), xlabel="throw speed relative to the ground / (m/s)",
ylabel="direction / degrees")
ax.grid(False)
plt.show()
print(f"Hill radius: (GM/3Ω²)^(1/3) = {R_HILL / 1e3:.2f} km, a (m/3M)^(1/3) = {A * (MU / (3 * GM_MARS))**(1 / 3) / 1e3:.2f} km; "
f"R/r_H = {R / R_HILL:.2f}")
print(f"climb from the hand to the pass: {phi(R_HILL) - phi(x_hand):.2f} J/kg, so no throw below {v_pass:.3f} m/s leaves")
lo, hi = 4.3, 4.5 # straight up: back down at lo, lost at hi
for _ in range(14):
mid = (lo + hi) / 2
lo, hi = (lo, mid) if outcome(fly(mid, 90))[0] == "lost" else (mid, hi)
kind, t_end, _ = outcome(on)
print(f"straight up with Mars on: lost from {hi:.3f} m/s; 4.5 m/s is {kind} after {t_end / 3600:.1f} h")
lost_speeds, ground_speeds = speeds[np.nonzero(fate == "l")[1]], speeds[np.nonzero(fate == "g")[1]]
print(f"the map: slowest lost throw {lost_speeds.min():.2f} m/s, fastest that lands {ground_speeds.max():.2f} m/s, "
f"every throw from {speeds[(fate == 'l').all(axis=0)].min():.2f} m/s lost; "
f"{(fate == 'f').sum()} of {fate.size} still flying")
Hill radius: (GM/3Ω²)^(1/3) = 21.30 km, a (m/3M)^(1/3) = 21.30 km; R/r_H = 0.29 climb from the hand to the pass: 8.93 J/kg, so no throw below 4.226 m/s leaves straight up with Mars on: lost from 4.402 m/s; 4.5 m/s is lost after 10.4 h the map: slowest lost throw 4.35 m/s, fastest that lands 5.65 m/s, every throw from 5.70 m/s lost; 15 of 5551 still flying
Above 4.23 m/s a stone has the energy to cross the pass at 21.3 km, but it still has to find it: straight up, 4.3 m/s falls back and from 4.40 m/s the stone is lost. The escape speed of a lone body is the right answer only deep inside its Hill sphere, with \(R/r_H\) small. Deimos fills 0.29 of its own, and the gap between the two dashed lines is what Mars steals.
How the game computes it
The state. Everything the game must remember sits in the plain object state, in SI units: the time t, the stone's position r and velocity v in the turning frame, the throw's speed and angle, the Mars flag tide, the warp step, the outcome line, and the trail of points for the lower view. The game starts with the throw loaded that comes back:
function fresh() {
return {speed: DATA.preset.speed, angle: DATA.preset.angle, tide: true, warp: DATA.warp_start,
running: true, flying: false, thrown: false, outcome: null, note: "press Throw",
t: 0, r: release(), v: [0, 0], trail: [], ghost: null, nextDot: 0, aim: null, zoom: DATA.zoom_min};
}
One tick. The browser runs frame through requestAnimationFrame whenever it is about to redraw. frame measures the real time since the last redraw, and tick turns at most 50 ms of it, times the warp, into game time and advances the stone in fixed 5 s steps until it has covered that much. A step is the classic fourth-order Runge-Kutta step: it evaluates the acceleration four times across the step and takes a weighted average. The acceleration is Hill's equations, and each line has its name: Deimos's gravity, the Coriolis force, the centrifugal force, and, behind the Mars flag, the tide, which together with the centrifugal force makes the \(3\Omega^2 x\) of the last section:
function accel(r, v, tide) {
const [x, y] = r, r3 = Math.hypot(x, y) ** 3;
let ax = -MU * x / r3 + 2 * OMEGA * v[1] + OMEGA ** 2 * x; // Deimos, Coriolis, centrifugal
let ay = -MU * y / r3 - 2 * OMEGA * v[0] + OMEGA ** 2 * y;
if (tide) { ax += 2 * OMEGA ** 2 * x; ay -= OMEGA ** 2 * y; } // Mars pulls the near side harder
return [ax, ay];
}
The three endings. After each step the game checks, in this order, whether the stone crossed the line through the astronaut on Mars's side, whether it went under the ground, and whether it got too far. A pass under 2 m is a hit; a higher one is reported and the stone flies on. Too far is three Hill radii, 64 km, and with Mars off only if the energy relative to the stars is not negative, which is exact for Deimos alone:
if (old[0] < 0 && s.r[0] < 0 && s.t > 600 && Math.sign(old[1]) !== Math.sign(s.r[1])) {
const f = old[1] / (old[1] - s.r[1]); // over the astronaut: interpolate the height
const p = axpy(old, f, [s.r[0] - old[0], s.r[1] - old[1]]), h = height(p);
if (h < H_ASTRO) {
...
if (height(s.r) < 0) {
...
if (Math.hypot(s.r[0], s.r[1]) > R_LOST && (s.tide || starEnergy(s.r, s.v) >= 0)) {
The throw. Pressing in the upper view and dragging sets the aim: the vector from the hand to the finger gives the direction, kept above the horizon, and its length the speed, rounded to 0.01 m/s, up to 7 m/s. Letting go throws:
const angle = dy > 0 ? Math.atan2(dy, dx) / DEG : (dx >= 0 ? 0 : 180); // only the upper half
const speed = clamp(Math.round(100 * Math.hypot(dx, Math.max(dy, 0)) / speedPx) / 100, 0, DATA.speed_max);
From the notebook into the game. Three things travel from the notebook into the page: Deimos's constants, the preset throw that brentq found, and the warp steps. They sit as JSON in a <script type="application/json" id="data"> element, and the game reads them from there without a request. The first hidden cell's accel is the game's, line for line; here the game's step, ported to Python, flies the two preset throws and is compared with solve_ivp:
def rk4_step(r, v, dt, tide):
k1r, k1v = v, accel(r, v, tide)
k2r, k2v = v + dt / 2 * k1v, accel(r + dt / 2 * k1r, v + dt / 2 * k1v, tide)
k3r, k3v = v + dt / 2 * k2v, accel(r + dt / 2 * k2r, v + dt / 2 * k2v, tide)
k4r, k4v = v + dt * k3v, accel(r + dt * k3r, v + dt * k3v, tide)
return (r + dt / 6 * (k1r + 2 * k2r + 2 * k3r + k4r), v + dt / 6 * (k1v + 2 * k2v + 2 * k3v + k4v))
def game_pass(speed, angle, tide=True):
"""The game's loop until the first pass over the astronaut: height in m and time in s."""
s = start(speed, angle)
r, v, t = s[:2], s[2:], 0.0
while True:
old = r
r, v = rk4_step(r, v, DT, tide)
t += DT
if np.hypot(*r) < R:
return None
if old[0] < 0 and r[0] < 0 and t > 600 and np.sign(old[1]) != np.sign(r[1]):
f = old[1] / (old[1] - r[1])
return np.hypot(*(old + f * (r - old))) - R, t - (1 - f) * DT
for angle in [0, 180]:
h_game, t_game = game_pass(v_hit[angle], angle)
h_exact, t_exact = first_pass(v_hit[angle], angle)
print(f"preset {v_hit[angle]:.4f} m/s at {angle:3d}°: game {h_game:.4f} m after {t_game / 3600:.3f} h, "
f"solve_ivp {h_exact:.4f} m, difference {1e3 * abs(h_game - h_exact):.1f} mm")
preset 3.6239 m/s at 0°: game 1.4940 m after 3.088 h, solve_ivp 1.5000 m, difference 6.0 mm preset 4.3229 m/s at 180°: game 1.4914 m after 2.547 h, solve_ivp 1.5000 m, difference 8.6 mm
The game's pass comes out 6 mm lower than solve_ivp's with the rotation and 9 mm lower against it, both well inside the 0.5 m between the hand and the top of the head.
The cell that writes the game. Sorted keys and fixed rounding make every run write the same bytes, and escaping </ as <\/ keeps a </ inside the data from ending the page's script element early. The page is a template string, in the collapsed cell:
Show code
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Throw a stone into orbit on Deimos</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98;
--globe: #ebe9e4; --mars: #f0dcd2; }
@media (prefers-color-scheme: dark) {
:root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab;
--globe: #232c42; --mars: #3d2c2c; }
}
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: 4px 10px; padding: 6px; box-sizing: border-box;
grid-template: "near far" minmax(0, 1fr) "read read" auto "ctrl ctrl" auto / minmax(0, 1fr) minmax(0, 1fr); }
body.upright { font-size: 14px; padding: 4px 6px;
grid-template: "near" minmax(0, 1fr) "far" minmax(0, 1.3fr) "read" auto "ctrl" auto / minmax(0, 1fr); }
.box { position: relative; min-height: 0; }
.box canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#nearbox { grid-area: near; } #farbox { grid-area: far; }
#near { touch-action: none; cursor: crosshair; }
#read { grid-area: read; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px;
font-variant-numeric: tabular-nums; }
.big { font-size: 1.35em; font-weight: 600; }
.acc { color: var(--accent); } .dim { color: var(--muted); }
#note { flex-basis: 100%; }
#ctrl { grid-area: ctrl; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
.group { display: flex; align-items: center; gap: 4px; }
#warp-val { min-width: 4.6em; text-align: center; font-variant-numeric: tabular-nums; }
button { font: inherit; color: var(--bg); background: var(--second); border: 0; border-radius: 4px; padding: 0.3em 0.8em;
touch-action: manipulation; }
button.step { padding: 0; width: 2em; }
#throw { background: var(--accent); }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }
</style>
</head>
<body>
<div class="box" id="nearbox"><canvas id="near" aria-label="The astronaut under Mars; drag from the hand to throw"></canvas></div>
<div class="box" id="farbox"><canvas id="far" aria-label="The whole moon with the Hill sphere and the stone's path"></canvas></div>
<div id="read">
<span class="acc">throw <span class="big" id="speed"></span></span>
<span>at <span id="angle"></span></span>
<span>height <span id="height"></span></span>
<span>t <span id="clock"></span></span>
<span class="dim" id="note"></span>
</div>
<div id="ctrl">
<button id="throw" title="Throw (T)">Throw</button>
<div class="group"><span>Speed</span><button class="step" id="speed-minus" title="0.01 m/s slower (-)"
aria-label="0.01 m/s slower">−</button><button class="step" id="speed-plus" title="0.01 m/s faster (+)"
aria-label="0.01 m/s faster">+</button></div>
<button id="mars" title="Mars on or off (M)"></button>
<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>
<button id="go" title="Play or pause (Space)"></button>
<button id="reset" title="Reset (R)">Reset</button>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
game_data = {
"mu": MU, "r": R, "omega": round(OMEGA, 13), "h0": H0, "h_astro": H_ASTRO, "dt": DT,
"r_hill": round(R_HILL, 1), "r_lost": round(R_LOST, 1),
"mars_deg": round(np.degrees(2 * np.arcsin(R_MARS / (A - R))), 2),
"preset": {"speed": round(v_hit[0], 5), "angle": 0},
"speed_step": 0.01, "speed_max": 7.0, "trail_every": 60,
"warps": [60, 300, 1200, 3600, 7200], "warp_names": ["1 min/s", "5 min/s", "20 min/s", "1 h/s", "2 h/s"],
"warp_start": 3, "zoom_min": 25000, "zoom_max": 70000,
}
# 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=(",", ":"), ensure_ascii=False).replace("</", "<\\/")
script = Path("minigame/deimos/game.js").read_text(encoding="utf-8")
Path("assets").mkdir(exist_ok=True)
Path("assets/deimos.html").write_text(TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script),
encoding="utf-8", newline="\n")
print(f"assets/deimos.html: Mars spans {game_data['mars_deg']}°, preset {game_data['preset']['speed']} m/s, "
f"{Path('assets/deimos.html').stat().st_size / 1024:.0f} kB")
assets/deimos.html: Mars spans 16.62°, preset 3.6239 m/s, 19 kB
Variations
- Phobos. Put in the inner moon's numbers from the same JPL tables, \(GM\) = 708,700 m³/s², \(R\) = 11.08 km, an orbit of 9,375 km in 7.65 h. The circular speed is 8.0 m/s and the escape speed 11.3 m/s, but the ground moves at 2.5 m/s and the Hill radius is 16.6 km: Phobos fills two thirds of its Hill sphere, and the pass is only 5.5 km above your head.
- Bennu. The asteroid has \(GM\) = 4.89 m³/s², a radius of about 245 m, and turns in 4.30 h. The escape speed is 0.20 m/s and the circular speed 0.14 m/s, while the ground moves at 0.10 m/s, so a throw with the spin needs half the escape speed on the ground, 0.10 m/s, and one against it three times that, 0.30 m/s. The game's one \(\Omega\) is both the rate of the orbit, which sets the tide, and the spin of the ground, as for a tidally locked moon, and Bennu circles the Sun in about 1.2 years, not 4.30 h. So the game cannot carry the Sun's tide: set \(\Omega\) to the spin and leave Mars off. Near the surface nothing is lost, because the Sun's Hill radius is 39 km, 159 times Bennu's radius.
- Throw from the leading point. Put the astronaut 90° from the point under Mars. Mars sits on the horizon, the tide pulls along the ground instead of up, and the throws that come back are no longer the same in both directions.
Further reading
- C. D. Murray and S. F. Dermott, Solar System Dynamics (Cambridge University Press, 1999), the chapter on the restricted three-body problem, for Hill's equations, the Jacobi constant, and the Hill sphere.
- D. J. Scheeres, Orbital Motion in Strongly Perturbed Environments (Springer, 2012), for orbits around asteroids and small moons, where the planet's or the Sun's tide and the body's spin matter.
- D. P. Hamilton and J. A. Burns, "Orbital stability zones about asteroids", Icarus 92, 118 (1991), on how far out a small body can hold a moonlet.
- JPL Solar System Dynamics, Planetary Satellite Physical Parameters and Planetary Satellite Mean Elements, for the numbers of Deimos and Phobos; M. Brozović, R. A. Jacobson, and R. S. Park, "Revised ephemerides of the Martian satellites, Phobos and Deimos", The Astronomical Journal 170, 42 (2025), for the orbits, and B. A. Archinal et al., "Report of the IAU Working Group on Cartographic Coordinates and Rotational Elements: 2015", Celestial Mechanics and Dynamical Astronomy 130, 22 (2018), for the radii.
- The SciPy reference for
scipy.integrate.solve_ivpandscipy.optimize.brentq. - For the browser side of
game.js: the Canvas API, which draws both views, and pointer events, which turn a drag into a throw, both documented on MDN. - Related tutorials on this site: solve_ivp from the ground up: the pendulum beyond small angles, Root finding with scipy.optimize: Halley's comet from Kepler's equation, and the other orbital Minigames, Gravity assist past Jupiter: faster in the Sun's frame, not in Jupiter's and Orbital plane change: a 28.5° turn costs almost as much as the climb to GEO.
- Download the notebook. It was executed with the library versions in the header.
- Download the game, one HTML file of 19 kB that runs offline in any browser.