Skip to content
SciStack
Minigame Python Beginner 25 min

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
Download notebook Save Mark as done

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 jupyterlab

Play: 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.

Download the game

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()
Left: Deimos, ±10 km, circled just above the ground by a stone thrown flat at 3.62 m/s. Right: height in m against time in h, two humps of 148 m; that stone is back at 1.5 m after 3.09 h, while one thrown at 3.70 m/s passes 38 m up.

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

\[v_c = \sqrt{\frac{GM}{R}}, \qquad T = \frac{2\pi R}{v_c},\]

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()
Mars off. Left: the far view, ±40 km, stones thrown straight up at 4.5, 5.0, and 5.6 m/s; the first two fall back against the rotation, the third leaves. Right: height in km against time in h; 5.4 m/s climbs 105 km and returns after 73 h, 5.6 m/s keeps rising.

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:

\[\tfrac12 v_\text{esc}^2 - \frac{GM}{R} = 0 \quad\Rightarrow\quad v_\text{esc} = \sqrt{\frac{2GM}{R}} = \sqrt2\, v_c.\]
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()
Left: ±30 km, a stone thrown straight up at 4.5 m/s; with Mars on it crosses the dashed Hill sphere at 21.3 km and leaves, with Mars off it falls back. Right: the effective potential on the line to Mars in J/kg, a valley at Deimos with passes at ±21.3 km, 4.23 m/s above the hand.

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,

\[r_H = \left(\frac{GM}{3\Omega^2}\right)^{1/3} = a\left(\frac{m}{3M}\right)^{1/3},\]

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:

\[\tfrac12 v^2 + \Phi_\text{eff} = \text{const}, \qquad \Phi_\text{eff} = -\frac{GM}{r} - \tfrac32\Omega^2 x^2.\]

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()
Where each throw ends, by speed relative to the ground in m/s and direction in degrees (0 flat with the rotation, 90 up). Gray: lands; red: lost to Mars; squares: still flying; dots: hits you. Red starts at the dashed 4.23 m/s line, left of the blue escape curve of Deimos alone.
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