Skip to content
SciStack
Minigame Python Intermediate 25 min

Relativistic flight at 0.9 c: aberration, Doppler colors, and the twin clocks

Afterwards you can explain why stars crowd ahead, turn blue, and brighten at 0.9 c, and why the traveler ages 4.23 years on a trip Earth counts as 9.71.

Field
Physics
Prerequisites
none beyond Python basics
Libraries
astropy 8.0.2matplotlib 3.11.2numpy 2.5.4pooch 1.9.0
Download notebook Save Mark as done

py-relativistic-flight.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.5.4 pooch==1.9.0 astropy==8.0.2 matplotlib==3.11.2 jupyterlab

Play: fly to Alpha Centauri

A ship leaves Earth for Alpha Centauri, 4.37 light years away, and you fly it. Its four windows, port, forward, starboard, and aft, show the sky as the crew sees it, with special relativity applied to each of 9,094 stars. They are four faces of a cube around the ship, two thirds of the sky; the patches overhead and underfoot are not drawn. The game starts paused at departure, at 0.01 c and one hour of Earth time per second of play, which the second slider raises to a year. Press Play or Space and raise the speed with the slider, its − and + buttons, or the ↑ and ↓ keys (after clicking the game). The speed slider is logarithmic up to 0.999999 c, so 0.9, 0.99, and 0.999 c sit about evenly spaced. At Alpha Centauri, press Turn around or T to fly home; R resets. Watch the dashed ring around the stars that sat beside the ship at rest, the colors in the forward and aft windows, and the two clocks. Flown at 0.9 c, the trip ends with Earth's clock at 4.86 years and the ship's at 2.12, and neither clock is wrong.

Download the game

Show code
// game.js: fly from Earth to Alpha Centauri and back at up to 0.999999 c, and watch the sky
// through four windows. Units: years, light years, c = 1. The notebook writes the sky
// (stars, Sun, planets, Alpha Centauri, blackbody colors) into the #data element.
"use strict";

const DATA = JSON.parse(document.getElementById("data").textContent);
const NIGHT = "#05070d";           // the sky inside the windows, in both themes
const LABEL = "#c9d0e3";           // names of the Sun and Alpha Centauri, on the night sky
const SHORT = {"Alpha Centauri": "α Cen"};   // for a window too narrow for the full name
const R_MAX = 6;                   // largest star radius, CSS px
const M_LIM = 7.5;                 // a star fainter than this magnitude gets no draw call
const NEAR_MIN = 1e-6;             // ly; a near object closer than this has no direction
const T_STAR = 5800;               // K, the Sun-like star of the effect lines
const WARP_MIN = 1 / 8766, WARP_MAX = 1;   // yr of Earth time per s: one hour to one year
const BETA_MIN = 0.01, BETA_MAX = 0.999999; // the speed slider's range, in c

// ---- the state: everything that changes, in one object
const DEFAULTS = {beta: 0.01, warp: WARP_MIN, t: 0, tau: 0, x: 0, heading: 1, running: false, arrived: false};
const state = {...DEFAULTS};

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

const XYZ = DATA.stars.xyz, V = DATA.stars.v, TEMP = DATA.stars.temp;
const TARGET = unit(DATA.near.find((o) => o.name === "Alpha Centauri").pos);

// ---- one tick: advance Earth time by the real time since the last frame
function tick(dt) {
  if (!state.running) return;
  let step = Math.min(dt, 0.05) * state.warp;          // Earth years; the cap stops jumps
  const goal = state.heading > 0 ? DATA.distance_ly : 0;
  const left = Math.abs(goal - state.x);
  if (state.beta * step >= left) {                      // land exactly on the goal
    step = left / state.beta;
    state.running = false;
    state.arrived = true;
  }
  state.t += step;
  state.tau += step * Math.sqrt(1 - state.beta ** 2);   // dτ = dt / γ
  state.x = state.arrived ? goal : state.x + state.heading * state.beta * step;
}

// ---- aberration and Doppler factor of one direction s (Earth's frame), seen from the ship
function aberrate(sx, sy, sz, n, beta, gamma, out) {
  const mu = sx * n[0] + sy * n[1] + sz * n[2];        // cos θ
  const D = gamma * (1 + beta * mu);
  const k = ((gamma - 1) * mu + gamma * beta) / D;
  out[0] = sx / D + k * n[0];                           // s′ = (s + ((γ − 1)μ + γβ) n) / D
  out[1] = sy / D + k * n[1];
  out[2] = sz / D + k * n[2];
  return D;
}

// ---- the four windows around the direction of flight n; up is celestial north
function basis() {
  const n = scale(TARGET, state.heading);
  const u = unit(sub([0, 0, 1], scale(n, n[2])));       // the pole, made perpendicular to n
  const r = cross(n, u);
  const windows = [
    {name: "port", axis: scale(r, -1), right: n},
    {name: "forward", axis: n, right: r},
    {name: "starboard", axis: r, right: scale(n, -1)},
    {name: "aft", axis: scale(n, -1), right: scale(r, -1)},
  ];
  return {n, u, r, windows};
}

// A direction goes to the window it faces most, projected as in a pinhole camera.
function project(s, b) {
  const a = dot(s, b.n), c = dot(s, b.r);
  let k;                                                // 0 port, 1 forward, 2 starboard, 3 aft
  if (Math.abs(a) >= Math.abs(c)) k = a >= 0 ? 1 : 3;   // more along the flight: forward or aft
  else k = c >= 0 ? 2 : 0;                              // more sideways: starboard or port
  const w = b.windows[k], depth = dot(s, w.axis);
  const x = dot(s, w.right) / depth, y = dot(s, b.u) / depth;
  return Math.abs(y) > 1 ? null : {k, x, y};
}

const colorIndex = (T) => Math.min(120, Math.max(0, Math.round(40 * (Math.log10(T) - 3))));

function starMarker(m) {                                // radius (CSS px) and opacity from magnitude
  const r = Math.min(R_MAX, Math.max(0.4, 0.5 * 10 ** (-0.12 * (m - 5))));
  return [r, Math.min(1, Math.max(0, (M_LIM - m) / 2))];
}

// Three significant digits with thousands separators: 25,300 and 0.00277.
const SUP = {"-": "⁻", 0: "⁰", 1: "¹", 2: "²", 3: "³", 4: "⁴", 5: "⁵", 6: "⁶", 7: "⁷", 8: "⁸", 9: "⁹"};
function sig3(x) {                                      // 3 significant digits; 2.5·10⁻¹³ when far from 1
  if (x !== 0 && (Math.abs(x) >= 1e6 || Math.abs(x) < 1e-3)) {
    const [m, e] = x.toExponential(2).split("e");
    return `${Number(m)}·10${[...String(Number(e))].map((c) => SUP[c]).join("")}`;
  }
  return Number(x.toPrecision(3)).toLocaleString("en-US", {maximumFractionDigits: 6});
}

function hudText(s) {                                   // the clock panel, line by line
  const gamma = 1 / Math.sqrt(1 - s.beta ** 2);
  const out = s.heading > 0;
  const left = out ? DATA.distance_ly - s.x : s.x;
  const status = s.arrived ? (out ? "Arrived at Alpha Centauri" : "Home") : (out ? "Outbound" : "Homebound");
  return [`Earth ${s.t.toFixed(2)} yr`, `Ship ${s.tau.toFixed(2)} yr`,
    `γ ${gamma.toFixed(2)} · to ${out ? "Alpha Centauri" : "Earth"} ${left.toFixed(2)} ly`, status];
}

// The speed slider moves log10(γβ): each decade of slow speeds, and near c each further
// nine of β (0.9, 0.99, 0.999), gets about the same length of slider.
const toSlider = (beta) => Math.log10(beta / Math.sqrt(1 - beta ** 2));
function fromSlider(p) {                                // inverse of toSlider, rounded to a readable β
  const u = 10 ** p, beta = u / Math.hypot(1, u);
  return beta <= 0.5 ? Number(beta.toPrecision(2)) : 1 - Number((1 - beta).toPrecision(2));
}
// ↑ and ↓ step through round speeds: 0.01, 0.015, 0.02, 0.03, 0.05, 0.07, 0.1, … 0.5, and
// above 0.5 the same round numbers for 1 - β: 0.7, 0.8, 0.85, 0.9, 0.93, … 0.99, … 0.999999.
const LADDER = [];
for (let e = -2; e <= -1; e++) for (const m of [1, 1.5, 2, 3, 5, 7]) LADDER.push(Number((m * 10 ** e).toPrecision(2)));
LADDER.splice(LADDER.indexOf(0.7));                     // the slow half ends at 0.5
for (let e = -1; e >= -6; e--) for (const m of [3, 2, 1.5, 1, 0.7, 0.5]) {
  const gap = Number((m * 10 ** e).toPrecision(2));     // 1 - β
  if (gap >= 1e-6 && gap < 0.5 && !LADDER.includes(1 - gap)) LADDER.push(1 - gap);
}

function speedText(beta) {                              // "0.01 c", "0.9 c", "0.999999 c"
  const digits = beta <= 0.5 ? beta.toPrecision(2) : beta.toFixed(Math.ceil(-Math.log10(1 - beta)) + 1);
  return `${Number(digits)} c`;
}

function warpText(w) {                                  // "1 hour", "3 months", "1 year"
  const d = w * 365.25;
  const [v, name] = d < 1 ? [d * 24, "hour"] : d < 7 ? [d, "day"] : d < 30.4 ? [d / 7, "week"]
    : w < 1 ? [w * 12, "month"] : [w, "year"];
  const r = Number(v.toPrecision(2));
  return `${r} ${name}${r === 1 ? "" : "s"}`;
}

// ---- drawing
const canvas = document.getElementById("sky");
const ctx = canvas.getContext("2d");
const $ = (id) => document.getElementById(id);
let view = {};                                          // sizes in CSS px, set by layout()

function layout() {
  const W = document.documentElement.clientWidth, dpr = window.devicePixelRatio || 1;
  const font = parseFloat(getComputedStyle(document.body).fontSize);   // the page's text size, 14 px and up
  const narrow = W < 600, pad = narrow ? 2 : 6, gap = 4, names = narrow ? 0 : Math.round(1.6 * font);
  const cols = document.documentElement.clientHeight > W ? 2 : 4, rows = 4 / cols;   // upright: two by two
  const height = (win) => pad + rows * (win + names) + (rows - 1) * gap;
  let win = (W - 2 * pad - (cols - 1) * gap) / cols;
  canvas.style.height = `${height(win)}px`;
  const over = $("panel").scrollHeight - $("panel").clientHeight;   // a short frame: smaller windows
  if (over > 0) win = Math.max(48, win - over / rows);
  const H = height(win), left = (W - cols * win - (cols - 1) * gap) / 2;
  canvas.style.height = `${H}px`;
  canvas.width = Math.round(W * dpr);
  canvas.height = Math.round(H * dpr);
  view = {W, H, dpr, narrow, pad, gap, names, cols, left, win, font, size: Math.min(1, Math.max(0.5, win / 274))};
  render();
}

const origin = (k) => [view.left + (k % view.cols) * (view.win + view.gap),   // window k's top left corner
  view.pad + Math.floor(k / view.cols) * (view.win + view.names + view.gap)];
const toPixel = (k, x, y) => {
  const [x0, y0] = origin(k);
  return [x0 + (1 + x) * view.win / 2, y0 + (1 - y) * view.win / 2];
};

function drawPoint(px, py, r, a) {
  ctx.globalAlpha = a;
  if (r < 1.2) ctx.fillRect(px - r, py - r, 2 * r, 2 * r);
  else { ctx.beginPath(); ctx.arc(px, py, r, 0, 2 * Math.PI); ctx.fill(); }
}

function render() {
  const b = basis(), beta = state.beta, gamma = 1 / Math.sqrt(1 - beta ** 2);
  const css = getComputedStyle(document.documentElement);
  ctx.setTransform(view.dpr, 0, 0, view.dpr, 0, 0);
  ctx.globalCompositeOperation = "source-over";
  ctx.globalAlpha = 1;
  ctx.fillStyle = css.getPropertyValue("--bg");
  ctx.fillRect(0, 0, view.W, view.H);
  ctx.save();
  ctx.beginPath();
  for (let k = 0; k < 4; k++) ctx.rect(...origin(k), view.win, view.win);
  ctx.fillStyle = NIGHT;
  ctx.fill();
  ctx.clip();

  // stars: sorted into buckets by color, so the fill style changes 121 times, not 9,000
  const buckets = DATA.colors.map(() => []);
  const s = [0, 0, 0];
  for (let i = 0; i < V.length; i++) {
    const D = aberrate(XYZ[3 * i], XYZ[3 * i + 1], XYZ[3 * i + 2], b.n, beta, gamma, s);
    const m = V[i] - 5 * Math.log10(D);                // each star's light grows by D²
    if (m > M_LIM) continue;
    const p = project(s, b);
    if (p) buckets[colorIndex(TEMP[i] * D)].push(p.k, p.x, p.y, m);
  }
  buckets.forEach((list, c) => {
    ctx.fillStyle = DATA.colors[c];
    for (let j = 0; j < list.length; j += 4) {
      const [r, a] = starMarker(list[j + 3]);
      drawPoint(...toPixel(list[j], list[j + 1], list[j + 2]), r * view.size, a);
    }
  });

  // the Sun, the planets, and Alpha Centauri: direction and brightness from where the ship is
  const ship = scale(TARGET, state.x);
  const labels = [];
  for (const o of DATA.near) {
    const rel = sub(o.pos, ship), d = Math.hypot(rel[0], rel[1], rel[2]);
    if (d < NEAR_MIN) continue;
    const D = aberrate(rel[0] / d, rel[1] / d, rel[2] / d, b.n, beta, gamma, s);
    const p = project(s, b);
    if (!p) continue;
    const m = o.v + 5 * Math.log10(d / Math.hypot(...o.pos)) - 5 * Math.log10(D);
    const [px, py] = toPixel(p.k, p.x, p.y);
    const [r, a] = starMarker(m);
    ctx.fillStyle = DATA.colors[colorIndex(o.temp * D)];
    drawPoint(px, py, r * view.size, a);
    if (o.label) labels.push({k: p.k, px, py, r: r * view.size, name: o.name});
  }

  // the ring: directions 90° from the flight in Earth's frame, where they appear now
  ctx.globalAlpha = 1;
  ctx.beginPath();
  let prev = null;
  for (let j = 0; j <= 360; j++) {
    const phi = (j * Math.PI) / 180;
    const ring = [0, 1, 2].map((i) => Math.cos(phi) * b.u[i] + Math.sin(phi) * b.r[i]);
    aberrate(ring[0], ring[1], ring[2], b.n, beta, gamma, s);
    const p = project(s, b);
    const [px, py] = p ? toPixel(p.k, p.x, p.y) : [0, 0];
    if (p && prev && prev.k === p.k) ctx.lineTo(px, py);
    else if (p) ctx.moveTo(px, py);
    prev = p;
  }
  const line = Math.max(1.2, 1.8 * view.size);
  ctx.strokeStyle = NIGHT;                              // a dark halo, so the dashes show on dense stars
  ctx.lineWidth = line + 4 * view.size;
  ctx.stroke();
  ctx.strokeStyle = css.getPropertyValue("--muted");
  ctx.lineWidth = line;
  ctx.setLineDash([5 * view.size + 1, 4 * view.size + 1]);
  ctx.stroke();
  ctx.setLineDash([]);

  // names above their objects (below, if that spot is taken), kept inside their window, with a dark halo
  ctx.font = `${view.font}px system-ui, sans-serif`;
  ctx.textAlign = "center";
  ctx.lineJoin = "round";
  ctx.lineWidth = 4;
  ctx.strokeStyle = NIGHT;
  ctx.fillStyle = LABEL;
  const placed = [];
  for (const {k, px, py, r, name} of labels) {
    const text = ctx.measureText(name).width > view.win - 8 ? SHORT[name] ?? name : name;
    const half = ctx.measureText(text).width / 2, [x0, y0] = origin(k);
    const x = Math.min(x0 + view.win - 4 - half, Math.max(x0 + 4 + half, px));
    let y = Math.max(y0 + (view.narrow ? 2.2 : 1.1) * view.font, py - r - 5);   // below a corner name
    const hits = (q) => Math.abs(q.x - x) < q.half + half && Math.abs(q.y - y) < view.font;
    if (placed.some(hits)) y = py + r + 4 + 0.8 * view.font;
    placed.push({x, y, half});
    ctx.strokeText(text, x, y);
    ctx.fillText(text, x, y);
  }
  ctx.textAlign = "left";
  ctx.restore();

  // window names: under the windows, or inside their top left corners when narrow
  ctx.font = `${view.font}px system-ui, sans-serif`;
  ctx.fillStyle = view.narrow ? LABEL : css.getPropertyValue("--muted");
  ctx.textBaseline = "top";
  b.windows.forEach((w, k) => {
    const [x0, y0] = origin(k);
    ctx.fillText(w.name, x0 + 4, view.narrow ? y0 + 3 : y0 + view.win + 0.25 * view.font);
  });
  ctx.textBaseline = "alphabetic";

  // the clock panel, the effect lines, and the controls
  const [earth, shipClock, info, status] = hudText(state);
  $("earth").textContent = earth;
  $("ship").textContent = shipClock;
  $("info").textContent = info;
  $("status").textContent = status;
  const ahead = gamma * (1 + beta), behind = gamma * (1 - beta);
  const cone = sig3((Math.acos(beta) * 180) / Math.PI);
  $("fx-ring").textContent = `${cone}°`;
  $("fx-hot").textContent = `${sig3(T_STAR * ahead)} K`;
  $("fx-cold").textContent = `${sig3(T_STAR * behind)} K`;
  $("sw-hot").style.background = DATA.colors[colorIndex(T_STAR * ahead)];
  $("sw-cold").style.background = DATA.colors[colorIndex(T_STAR * behind)];
  $("fx-bright").textContent = `×${sig3(ahead ** 4)}`;
  $("fx-dark").textContent = `×${sig3(behind ** 4)}`;
  $("fx-short").textContent = `cone ${cone}° · D ahead ${sig3(ahead)}`;
  $("speed").value = toSlider(beta);
  $("speed-val").textContent = speedText(beta);
  $("warp").value = Math.log(state.warp / WARP_MIN) / Math.log(WARP_MAX / WARP_MIN);
  $("warp-val").textContent = warpText(state.warp);
  $("play").textContent = state.running ? "Pause" : "Play";
  $("play").disabled = state.arrived;
  $("turn").disabled = !(state.arrived && state.heading > 0);
  $("slower").disabled = state.beta <= BETA_MIN;
  $("faster").disabled = state.beta >= BETA_MAX;
}

// ---- controls, keys, and the loop
function set(partial) {
  Object.assign(state, partial);
  render();
}
const setSpeed = (beta) => set({beta: Math.min(BETA_MAX, Math.max(BETA_MIN, beta))});
function nudgeSpeed(sign) {                             // ↑ and ↓: the next round speed of LADDER
  const steps = sign > 0 ? LADDER : [...LADDER].reverse();
  const next = steps.find((b) => sign * (b - state.beta) > 1e-12);
  if (next !== undefined) setSpeed(next);
}
const togglePlay = () => { if (!state.arrived) set({running: !state.running}); };
const turnAround = () => { if (state.arrived && state.heading > 0) set({heading: -1, arrived: false, running: true}); };

$("speed").min = toSlider(BETA_MIN);
$("speed").max = toSlider(BETA_MAX);
$("speed").addEventListener("input", (e) => setSpeed(fromSlider(Number(e.target.value))));
$("warp").addEventListener("input", (e) => set({warp: WARP_MIN * (WARP_MAX / WARP_MIN) ** Number(e.target.value)}));
$("slower").addEventListener("click", () => nudgeSpeed(-1));
$("faster").addEventListener("click", () => nudgeSpeed(1));
$("play").addEventListener("click", togglePlay);
$("reset").addEventListener("click", () => set({...DEFAULTS}));
$("turn").addEventListener("click", turnAround);
document.addEventListener("keydown", (e) => {
  const actions = {" ": togglePlay, ArrowUp: () => nudgeSpeed(1), ArrowDown: () => nudgeSpeed(-1),
    r: () => set({...DEFAULTS}), t: turnAround};
  const act = actions[e.key.length === 1 ? e.key.toLowerCase() : e.key];
  const native = (e.target instanceof HTMLInputElement && e.key.startsWith("Arrow"))
    || (e.target instanceof HTMLButtonElement && e.key === " ");   // the focused control handles these
  if (act && !native) {
    e.preventDefault();
    act();
  }
});

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

window.addEventListener("resize", layout);
layout();
layout();                                               // again, now that the panel holds its text
requestAnimationFrame(frame);
window.game = {state, set, render};

The stars crowd toward the bow

At rest the stars fill all four windows. At 0.9 c they crowd into a cone around the bow, the dashed ring closes to a circle 25.8° around the forward window's center, and the side windows thin out and redden.

The stills come from the game's own sky, the Yale Bright Star Catalogue plus the Sun, planets, and Alpha Centauri on departure day, 1 January 2027. Two hidden cells load it and hold the game's formulas in Python.

Show code
import gzip
import json
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from matplotlib import patheffects
import pooch
import astropy.units as u
from astropy.coordinates import get_body_barycentric, solar_system_ephemeris
from astropy.time import Time

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"
NIGHT = "#05070d"                                  # the sky inside the game's windows
HALO = [patheffects.withStroke(linewidth=3, foreground=NIGHT)]   # keeps text legible on dense stars

BETA, L_LY, T_STAR = 0.9, 4.37, 5800               # speed / c, distance / ly (Kervella et al. 2016), a Sun-like star / K
DEPARTURE = Time("2027-01-01 12:00", scale="tdb")
AU_LY = 1 / 63241.077                              # light years per astronomical unit

# The Yale Bright Star Catalogue, 5th revised edition, CDS catalog V/50: 9,110 fixed-width records.
path = pooch.retrieve(
    "https://cdsarc.cds.unistra.fr/ftp/V/50/catalog.gz",
    known_hash="sha256:3dc44b1e90be8fbe5bcc7656032560f51275f985c7e3f783c9028e1838ec7bed",
)
with gzip.open(path, "rt", encoding="ascii") as f:
    records = [line.ljust(197) for line in f.read().splitlines()]   # trailing blanks are stripped in the file
rows = [r for r in records if r[75:83].strip()]   # no position: 9 novae, M 31, two globular and two open clusters

def column(first, last, blank=np.nan):
    """Bytes first to last (1-based, as in the CDS ReadMe) of every row, as floats."""
    return np.array([r[first - 1:last].strip() or blank for r in rows], dtype=float)

hr = column(1, 4).astype(int)
ra = np.radians(15 * (column(76, 77) + column(78, 79) / 60 + column(80, 83) / 3600))
sign = np.where([r[83] == "-" for r in rows], -1, 1)
dec = np.radians(sign * (column(85, 86) + column(87, 88) / 60 + column(89, 90) / 3600))
vmag = column(103, 107)
bv = column(110, 114)
no_color = np.isnan(bv)
bv[no_color] = np.nanmedian(bv)

# Temperature from the measured color (Ballesteros 2012, EPL 97, 34008).
temp = 4600 * (1 / (0.92 * bv + 1.7) + 1 / (0.92 * bv + 0.62))
xyz = np.column_stack([np.cos(dec) * np.cos(ra), np.cos(dec) * np.sin(ra), np.sin(dec)])

# Alpha Centauri A and B (HR 5459, 5460) become one near object; the rest are the distant sky.
is_acen = np.isin(hr, [5459, 5460])
acen_dir, acen_temp = xyz[hr == 5459][0], temp[hr == 5459][0]
acen_v = -2.5 * np.log10(np.sum(10 ** (-0.4 * vmag[is_acen])))
stars = dict(xyz=xyz[~is_acen], v=vmag[~is_acen], temp=temp[~is_acen])

print(f"catalog.gz, {Path(path).stat().st_size / 1024:.0f} kB: {len(records)} entries, {len(rows)} with a position, "
      f"{no_color.sum()} without a color, V from {vmag.min():.2f} to {vmag.max():.2f}; {len(stars['v'])} distant stars")
catalog.gz, 560 kB: 9110 entries, 9096 with a position, 310 without a color, V from -1.46 to 7.96; 9094 distant stars
Show code
# ---- the Sun, the planets, and Alpha Centauri, relative to Earth at departure, in ly
H_PLANET = {"mercury": -0.613, "venus": -4.384, "mars": -1.601, "jupiter": -9.395,   # Mallama and Hilton 2018
            "saturn": -8.914, "uranus": -7.110, "neptune": -7.00}
with solar_system_ephemeris.set("builtin"):
    at = {body: get_body_barycentric(body, DEPARTURE).xyz.to_value(u.au)
          for body in ["earth", "sun", *H_PLANET]}
sun_au = at["sun"] - at["earth"]
near = [dict(name="Sun", label=True, pos=sun_au * AU_LY, temp=5772,
             v=-26.74 + 5 * np.log10(np.linalg.norm(sun_au)))]          # -26.74 at 1 AU
for body, H in H_PLANET.items():
    r, delta = np.linalg.norm(at[body] - at["sun"]), np.linalg.norm(at[body] - at["earth"])
    near.append(dict(name=body.capitalize(), label=False, pos=(at[body] - at["earth"]) * AU_LY,
                     temp=5772, v=H + 5 * np.log10(r * delta)))           # sunlight: the Sun's color
near.append(dict(name="Alpha Centauri", label=True, pos=L_LY * acen_dir, temp=acen_temp, v=acen_v))

# ---- blackbody colors: Planck's law times the CIE 1931 color matching functions
def planck(lam, T):
    """Spectral radiance of a blackbody, W / (m² sr m), at wavelength lam / m."""
    h, c, k = 6.62607015e-34, 299792458.0, 1.380649e-23
    return 2 * h * c**2 / lam**5 / np.expm1(h * c / (lam * k * T))

def lobe(lam, mu, s1, s2):
    return np.exp(-0.5 * ((lam - mu) / np.where(lam < mu, s1, s2)) ** 2)

lam = np.arange(360.0, 831.0)                       # nm; CMF fit of Wyman, Sloan, and Shirley 2013
cmf = np.array([
    1.056 * lobe(lam, 599.8, 37.9, 31.0) + 0.362 * lobe(lam, 442.0, 16.0, 26.7) - 0.065 * lobe(lam, 501.1, 20.4, 26.2),
    0.821 * lobe(lam, 568.8, 46.9, 40.5) + 0.286 * lobe(lam, 530.9, 16.3, 31.1),
    1.217 * lobe(lam, 437.0, 11.8, 36.0) + 0.681 * lobe(lam, 459.0, 26.0, 13.8),
])
XYZ_TO_RGB = np.array([[3.2406, -1.5372, -0.4986], [-0.9689, 1.8758, 0.0415], [0.0557, -0.2040, 1.0570]])

def blackbody_hex(T):
    rgb = np.clip(XYZ_TO_RGB @ (cmf @ planck(lam * 1e-9, T)), 0, None)
    rgb = rgb / rgb.max()                           # chromaticity only; brightness is separate
    rgb = np.where(rgb <= 0.0031308, 12.92 * rgb, 1.055 * rgb ** (1 / 2.4) - 0.055)
    return "#" + "".join(f"{round(255 * c):02x}" for c in rgb)

COLORS = [blackbody_hex(10 ** (3 + i / 40)) for i in range(121)]   # 1,000 K to 1,000,000 K

# ---- the game's formulas in Python, same names as in game.js
def gamma_of(beta):
    return 1 / np.sqrt(1 - beta**2)

def aberrate(s, n, beta):
    """Directions s (Earth's frame, rows) as seen from a ship moving at beta along n; returns s′ and D."""
    g = gamma_of(beta)
    mu = s @ n                                      # cos θ
    D = g * (1 + beta * mu)
    s_prime = (s + ((g - 1) * mu + g * beta)[..., None] * n) / D[..., None]
    return s_prime, D

def basis(heading=1):
    n = heading * acen_dir
    up = np.array([0.0, 0.0, 1.0]) - n[2] * n       # celestial north, perpendicular to n
    up /= np.linalg.norm(up)
    right = np.cross(n, up)
    windows = {"port": (-right, n), "forward": (n, right), "starboard": (right, -n), "aft": (-n, -right)}
    return n, up, right, windows

WINDOW_ORDER = ["port", "forward", "starboard", "aft"]

def project(s, b):
    """Window index (0 to 3, -1 if above or below the strip) and pinhole coordinates x, y."""
    n, up, right, windows = b
    a, c = s @ n, s @ right
    k = np.where(np.abs(a) >= np.abs(c), np.where(a >= 0, 1, 3), np.where(c >= 0, 2, 0))
    axis = np.array([windows[w][0] for w in WINDOW_ORDER])[k]
    rgt = np.array([windows[w][1] for w in WINDOW_ORDER])[k]
    depth = np.sum(s * axis, axis=-1)
    x, y = np.sum(s * rgt, axis=-1) / depth, (s @ up) / depth
    return np.where(np.abs(y) > 1, -1, k), x, y

def color_index(T):
    return np.clip(np.round(40 * (np.log10(T) - 3)), 0, 120).astype(int)

M_LIM, R_MAX = 7.5, 6

def star_marker(m):
    """Radius in CSS px of a 274 px window, and opacity, from the magnitude seen on the ship."""
    r = np.clip(0.5 * 10 ** (-0.12 * (m - 5)), 0.4, R_MAX)
    return r, np.clip((M_LIM - m) / 2, 0, 1)

def sig3(x):
    """Three significant digits with thousands separators, as the game prints them."""
    return f"{float(f'{x:.3g}'):,.6f}".rstrip("0").rstrip(".")

def sky(beta, x_ly, heading=1):
    """Everything the game draws at one setting: window, x, y, color index, magnitude, label."""
    b = basis(heading)
    s, D = aberrate(stars["xyz"], b[0], beta)
    m = stars["v"] - 5 * np.log10(D)
    k, x, y = project(s, b)
    keep = (m <= M_LIM) & (k >= 0)
    out = [dict(k=k[keep], x=x[keep], y=y[keep], c=color_index(stars["temp"][keep] * D[keep]),
                m=m[keep], label=None)]
    ship = x_ly * acen_dir
    for o in near:
        rel = o["pos"] - ship
        d = np.linalg.norm(rel)
        if d < 1e-6:                                # at the ship: no direction
            continue
        s1, D1 = aberrate(rel[None] / d, b[0], beta)
        k1, x1, y1 = project(s1, b)
        m1 = o["v"] + 5 * np.log10(d / np.linalg.norm(o["pos"])) - 5 * np.log10(D1)
        if k1[0] >= 0:
            out.append(dict(k=k1, x=x1, y=y1, c=color_index(o["temp"] * D1), m=m1,
                            label=o["name"] if o["label"] else None))
    return out

def ring(beta, heading=1):
    """The directions 90° from the flight in Earth's frame, where they appear on the ship."""
    n, up, right, _ = b = basis(heading)
    phi = np.radians(np.arange(361))
    s, _ = aberrate(np.outer(np.cos(phi), up) + np.outer(np.sin(phi), right), n, beta)
    return project(s, b)

def draw_window(ax, beta, x_ly, which, heading=1, show_ring=True, scale=None, labels=True):
    """One window of the game, drawn with Matplotlib on axes ax."""
    k0 = WINDOW_ORDER.index(which)
    ax.set_axis_off()
    ax.add_patch(plt.Rectangle((-1, -1), 2, 2, color=NIGHT, zorder=0))
    ax.set(xlim=(-1, 1), ylim=(-1, 1), aspect="equal")
    if scale is None:                               # points per CSS px of a 274 px game window
        scale = ax.get_position().width * ax.figure.get_figwidth() * 72 / 274
    for group in sky(beta, x_ly, heading):
        sel = group["k"] == k0
        order = np.argsort(-group["m"][sel])        # faint first, bright on top
        r, a = star_marker(group["m"][sel][order])
        rgba = [(*plt.matplotlib.colors.to_rgb(COLORS[c]), al) for c, al in zip(group["c"][sel][order], a)]
        ax.scatter(group["x"][sel][order], group["y"][sel][order], s=(2 * r * scale) ** 2,
                   c=rgba, lw=0, zorder=2)
        if labels and group["label"] and sel.any():    # centered above the object, as in the game
            ax.annotate(group["label"], (group["x"][sel][0], group["y"][sel][0]), xytext=(0, r[0] * scale + 3),
                        textcoords="offset points", ha="center", va="bottom", color="#c9d0e3",
                        path_effects=HALO, annotation_clip=True, zorder=4)
    if show_ring:
        k, x, y = ring(beta, heading)
        on = k == k0
        for seg in np.split(np.arange(361), np.flatnonzero(np.diff(on.astype(int))) + 1):
            if on[seg[0]]:                          # a dark halo, so the dashes show on dense stars
                ax.plot(x[seg], y[seg], color=NIGHT, lw=4.5, zorder=3)
                ax.plot(x[seg], y[seg], color=MUTED, lw=1.2, ls="--", zorder=3)
    return ax

def hud_text(t, tau, beta, x_ly, heading, arrived):
    """The game's clock panel, line by line, as hudText in game.js writes it."""
    out = heading > 0
    left = L_LY - x_ly if out else x_ly
    status = ("Arrived at Alpha Centauri" if out else "Home") if arrived else ("Outbound" if out else "Homebound")
    return [f"Earth {t:.2f} yr", f"Ship {tau:.2f} yr",
            f"γ {gamma_of(beta):.2f} · to {'Alpha Centauri' if out else 'Earth'} {left:.2f} ly", status]

def draw_hud(ax, lines):
    ax.set_axis_off()
    for (text, color, size), y in zip(zip(lines, [INK, ACCENT, INK, INK], [20, 20, 11, 11]),
                                       [0.86, 0.6, 0.34, 0.14]):
        ax.text(0.02, y, text, color=color, fontsize=size, transform=ax.transAxes, va="center")

n0 = basis()[0]
sun_dir = near[0]["pos"] / np.linalg.norm(near[0]["pos"])
ra_ac, dec_ac = np.degrees(np.arctan2(acen_dir[1], acen_dir[0])) % 360, np.degrees(np.arcsin(acen_dir[2]))
print(f"Alpha Centauri at RA {ra_ac:.2f}°, Dec {dec_ac:.2f}°, {L_LY} ly; "
      f"the Sun {np.linalg.norm(sun_au):.2f} AU away, {np.degrees(np.arccos(sun_dir @ n0)):.0f}° from the bow")
Alpha Centauri at RA 219.90°, Dec -60.84°, 4.37 ly; the Sun 0.98 AU away, 56° from the bow
Show code
fig, axes = plt.subplots(2, 4, figsize=(8, 4.4), gridspec_kw=dict(wspace=0.04, hspace=0.08))
for row, beta in zip(axes, [0.0, BETA]):
    for ax, which in zip(row, WINDOW_ORDER):
        draw_window(ax, beta, 0.1, which, labels=beta == 0)    # names at rest only: the ring is the point below
for ax, which in zip(axes[1], WINDOW_ORDER):
    ax.text(0, -1.08, which, ha="center", va="top", color=INK)
for row, text in zip(axes, ["at rest", "0.9 c"]):
    row[0].text(-1.08, 0, text, ha="right", va="center", rotation=90, color=INK)
cone = np.degrees(np.arccos(BETA))
axes[1, 1].text(0, np.tan(np.radians(cone)) + 0.06, f"{sig3(cone)}°", ha="center", va="bottom", color=ACCENT,
                path_effects=HALO)
plt.show()
The four windows, port, forward, starboard, and aft, 0.1 light years from Earth. Top, at rest: stars everywhere, the dashed ring a vertical line through the side windows. Bottom, at 0.9 c: the stars crowd inside a 25.8° ring in the forward window, and the side windows are sparse and dark.

The ship runs into the light. Rain falling straight down hits the windshield of a moving car from ahead, and the faster the car, the further ahead it seems to come from. Starlight does the same.

Throughout, β = v/c is the speed as a fraction of the speed of light, θ the angle between a star and the direction of flight as Earth measures it, and θ′ the same angle as seen on the ship. Relativity makes the tilt exact:

\[\cos\theta' = \frac{\cos\theta + \beta}{1 + \beta\cos\theta}.\]

A star beside the ship, θ = 90°, has cos θ′ = β, and every star of Earth's forward half lands inside that ring. A cone of half-angle α cuts a cap of (1 − cos α)/2 out of the sky, here (1 − β)/2:

cone = np.degrees(np.arccos(BETA))                  # where θ = 90° appears on the ship
print(f"θ = 90° appears at θ′ = {cone:.1f}°; the cap inside it is {100 * (1 - BETA) / 2:.1f} % of the sky")

n, *_ = b = basis()
ahead = stars["xyz"] @ n > 0                        # Earth's forward half of the sky
s_ship, D = aberrate(stars["xyz"], n, BETA)
inside = s_ship @ n >= BETA                         # inside the cone, on the ship
print(f"forward half on Earth: {ahead.sum()} of {ahead.size} stars, {(ahead & inside).sum()} of them inside {cone:.1f}°")
k_rest, k_fly = project(stars["xyz"], b)[0], project(s_ship, b)[0]
side_rest, side_fly = np.isin(k_rest, [0, 2]), np.isin(k_fly, [0, 2])   # port and starboard
print(f"side windows: {side_rest.sum()} stars at rest, {side_fly.sum()} at {BETA} c, of which "
      f"{(side_fly & side_rest).sum()} were there at rest and {(side_fly & (k_rest == 3)).sum()} in the aft window")
theta = np.degrees(np.arccos(stars["xyz"][side_fly] @ n))
print(f"at {BETA} c they were {theta.min():.0f}° to {theta.max():.0f}° from the bow, with D from {D[side_fly].max():.2f} to {D[side_fly].min():.2f}")
θ = 90° appears at θ′ = 25.8°; the cap inside it is 5.0 % of the sky
forward half on Earth: 4538 of 9094 stars, 4538 of them inside 25.8°
side windows: 3247 stars at rest, 1016 at 0.9 c, of which 237 were there at rest and 779 in the aft window
at 0.9 c they were 123° to 169° from the bow, with D from 1.18 to 0.27

Half the catalog, 4,538 stars, now sits in 5.0 % of the sky. Of the 1,016 stars in the side windows, 779 came from the aft window.

Blue ahead, red behind

At 0.9 c the forward window turns blue-white and the aft window dull red. The Sun, straight behind the ship, glows like an ember.

Show code
fig = plt.figure(figsize=(7, 7.6))
grid = fig.add_gridspec(3, 2, height_ratios=[1, 1, 0.09], hspace=0.12, wspace=0.04)
for i, beta in enumerate([0.0, BETA]):
    for j, which in enumerate(["forward", "aft"]):
        ax = draw_window(fig.add_subplot(grid[i, j]), beta, 0.1, which, show_ring=False)
        if beta > 0:
            D = gamma_of(beta) * (1 + (beta if which == "forward" else -beta))
            ax.text(0, -0.92, f"D = {sig3(D)}: {sig3(T_STAR)} K → {sig3(D * T_STAR)} K",
                    ha="center", va="bottom", color=ACCENT, path_effects=HALO)
        if i == 0:
            ax.text(0, 1.04, which, ha="center", va="bottom", color=INK)
        if j == 0:
            ax.text(-1.04, 0, "at rest" if beta == 0 else "0.9 c", ha="right", va="center", rotation=90, color=INK)
strip = fig.add_subplot(grid[2, :])
T_grid = np.geomspace(1000, 40000, 400)
strip.imshow([[plt.matplotlib.colors.to_rgb(COLORS[c]) for c in color_index(T_grid)]],
             extent=(1000, 40000, 0, 1), aspect="auto")
strip.set_xscale("log")
strip.set(yticks=[], xticks=[1330, T_STAR, 25300], xticklabels=["1,330", "5,800", "25,300"],
          xlabel="temperature / K", xlim=(1000, 40000))
strip.minorticks_off()
strip.grid(False)
strip.spines[["left", "bottom"]].set_visible(False)
plt.show()
Forward and aft windows, at rest (top) and at 0.9 c (bottom), 0.1 light years out. At 0.9 c the forward stars are blue-white, D = 4.36, and the aft window holds little but the red Sun, D = 0.229. A strip of blackbody colors from 1,000 to 40,000 K marks 1,330, 5,800, and 25,300 K.

The color is set by the Doppler factor D, the frequency the ship measures divided by the one Earth measures. In Earth's frame two effects multiply. The ship runs into the waves, so crests from ahead reach it more often, by the factor 1 + β cos θ: 1.9 head-on, 1 for light that crosses its path at right angles, 0.1 from behind. And the ship's clock ticks slower by γ = 1/√(1 − β²), as the clock section explains. A slow clock counts more crests per tick, so it raises every frequency it measures by γ. Together,

\[D = \gamma\,(1 + \beta\cos\theta).\]

Every frequency in a star's spectrum is multiplied by D, so a blackbody stays a blackbody at D times its temperature, since the exponent of Planck's law holds frequency only as hν/kT. The game takes each star's temperature from its measured color in the catalog (Ballesteros 2012) and paints it at the shifted one:

gamma = gamma_of(BETA)
print(f"γ = {gamma:.3f}")
for name, theta in [("ahead", 0), ("on the ring", 90), ("behind", 180)]:
    D = gamma * (1 + BETA * np.cos(np.radians(theta)))
    print(f"{name:12s} θ = {theta:3d}°   D = {D:.3f}   5,800 K → {sig3(D * T_STAR):>6s} K")
# abeam on the ship, θ′ = 90°: invert the aberration formula, cos θ = (cos θ′ - β) / (1 - β cos θ′)
theta = np.degrees(np.arccos(-BETA))
D = gamma * (1 + BETA * np.cos(np.radians(theta)))
print(f"{'abeam':12s} θ = {theta:3.0f}°   D = {D:.3f}   5,800 K → {sig3(D * T_STAR):>6s} K")
γ = 2.294
ahead        θ =   0°   D = 4.359   5,800 K → 25,300 K
on the ring  θ =  90°   D = 2.294   5,800 K → 13,300 K
behind       θ = 180°   D = 0.229   5,800 K →  1,330 K
abeam        θ = 154°   D = 0.436   5,800 K →  2,530 K

Head-on, the 5,800 K star becomes 25,300 K, blue-white; straight behind it cools to 1,330 K, the ember in the aft window. The ring stars, θ = 90°, get D = γ = 2.29 from the slow clock alone and turn blue: the ship does not run into light that crosses its path. The stars exactly abeam on the ship, θ′ = 90°, were at θ = 154° in Earth's frame and get 1/γ = 0.436. The red of the side windows is the light of stars that were behind you.

Bright ahead, dark behind

At 0.9 c the sky straight ahead shines 361 times as bright as at rest, and straight behind it keeps 0.00277 of its light, nearly black.

Show code
fig, axes = plt.subplots(2, 3, figsize=(7, 4.9), gridspec_kw=dict(wspace=0.04, hspace=0.06))
for j, beta in enumerate([0.0, 0.5, BETA]):
    axes[0, j].text(0, 1.04, f"{beta:.1f} c" if beta else "at rest", ha="center", va="bottom", color=INK)
    for i, which in enumerate(["forward", "aft"]):
        draw_window(axes[i, j], beta, 0.1, which, show_ring=False)
        D = gamma_of(beta) * (1 + (beta if which == "forward" else -beta))
        axes[i, j].text(0, -0.92, f"×{sig3(D**4)}", ha="center", va="bottom", color=ACCENT, path_effects=HALO)
for ax, which in zip(axes[:, 0], ["forward", "aft"]):
    ax.text(-1.04, 0, which, ha="right", va="center", rotation=90, color=INK)
plt.show()
Forward (top) and aft (bottom) windows at rest, at 0.5 c, and at 0.9 c, 0.1 light years out. The forward window fills with more and brighter stars, sky brightness ×1, ×9, ×361; the aft window empties to the Sun, ×1, ×0.111, ×0.00277.

Two factors of D² multiply. A star's photons arrive D times as often and each carries D times the energy, so its total light, summed over all wavelengths, grows by D². And the stars sit closer together: aberration squeezes a small patch of sky into a solid angle D² times smaller, dΩ′ = dΩ/D², so each patch holds D² times as many stars. The first section's factor of 10, half the sky in 5.0 %, is the average over the forward half, where D² falls from 19.0 ahead to 5.26 at the ring. So the sky's total light per patch, B, grows as

\[B' = D^4 B.\]

The eye sees much less of the gain. A star ahead shines like a blackbody D times hotter, worth D⁴ by itself, on a disk too small to see that covers D² times less sky: together the D² of its total light. The visible band gains less still, because a hotter blackbody puts most of its extra light at short wavelengths, at 25,300 K in the ultraviolet. The cell evaluates Planck's law at 555 nm, where the eye is most sensitive, and divides by D² for the smaller disk:

D_ahead, D_behind = gamma * (1 + BETA), gamma * (1 - BETA)
print(f"one star's light, ahead:     D²  = {D_ahead**2:6.1f}")
print(f"stars per patch:             D²  = {D_ahead**2:6.1f} ahead, {gamma**2:.2f} at the ring; "
      f"{0.5 / ((1 - BETA) / 2):.0f} for the whole forward half")
print(f"sky brightness per patch:    D⁴  = {D_ahead**4:6.0f} ahead, {D_behind**4:.5f} behind")
# the eye's sensitivity peaks at 555 nm; the 5,800 K star straight ahead
lam = 555e-9
visible = planck(lam, D_ahead * T_STAR) / (D_ahead**2 * planck(lam, T_STAR))
print(f"visible light at 555 nm:     ×{visible:.1f} instead of ×{D_ahead**2:.1f}")
one star's light, ahead:     D²  =   19.0
stars per patch:             D²  =   19.0 ahead, 5.26 at the ring; 10 for the whole forward half
sky brightness per patch:    D⁴  =    361 ahead, 0.00277 behind
visible light at 555 nm:     ×2.5 instead of ×19.0

In the light the eye sees most, the 5,800 K star ahead gains a factor of 2.5, not 19. The game shows total light, so its bow is brighter than a crew would see it. It is also emptier: the catalog stops near the naked-eye limit, so the fainter stars the boost lifts into view are missing.

The clock on board runs slow

At 0.9 c the clock panel reads like this at Alpha Centauri and back home:

Show code
t_arrive, tau_arrive = L_LY / BETA, L_LY / (gamma_of(BETA) * BETA)   # what the game's clocks read there
fig, axes = plt.subplots(1, 2, figsize=(7, 2.2))
settings = [("β = 0.9, at Alpha Centauri, heading out", hud_text(t_arrive, tau_arrive, BETA, L_LY, 1, True)),
            ("β = 0.9, back at Earth after Turn around", hud_text(2 * t_arrive, 2 * tau_arrive, BETA, 0.0, -1, True))]
for ax, (setting, lines) in zip(axes, settings):
    draw_hud(ax, lines)
    ax.text(0.02, 1.12, setting, color=MUTED, transform=ax.transAxes, va="center")
plt.show()
The game's clock panel at 0.9 c. Left, at Alpha Centauri: Earth 4.86 yr, ship 2.12 yr. Right, back at Earth after Turn around: Earth 9.71 yr, ship 4.23 yr. Both times the ship's clock shows less than half of Earth's.

A clock that moves at β, as measured in a frame that never changes its motion (an inertial frame), ticks slower by γ:

\[d\tau = \frac{dt}{\gamma},\]

with t Earth's time and τ the ship's. For every step Δt of Earth time the game adds Δt/γ to the ship's clock, at whatever speed you fly. The cell does the same sum on a fixed grid:

t = np.linspace(0, 2 * L_LY / BETA, 2001)           # Earth time / yr; sample 1000 is the arrival
beta = np.full_like(t, BETA)                        # any speed history fits here
dt = np.diff(t)
tau = np.concatenate([[0], np.cumsum(dt * np.sqrt(1 - beta[:-1] ** 2))])   # dτ = dt / γ
heading = np.where(t[:-1] < L_LY / BETA, 1, -1)
x = np.concatenate([[0], np.cumsum(heading * beta[:-1] * dt)])            # distance from Earth / ly

g = gamma_of(BETA)
for name, i, k in [("arrival", 1000, 1), ("home", -1, 2)]:
    print(f"{name:8s} sum: Earth {t[i]:.2f} yr, ship {tau[i]:.2f} yr   "
          f"formula: Earth {k * L_LY / BETA:.2f} yr, ship {k * L_LY / (g * BETA):.2f} yr")
print(f"the trip in the ship's frame: {L_LY / g:.2f} ly at {BETA} c takes {L_LY / g / BETA:.2f} yr")
arrival  sum: Earth 4.86 yr, ship 2.12 yr   formula: Earth 4.86 yr, ship 2.12 yr
home     sum: Earth 9.71 yr, ship 4.23 yr   formula: Earth 9.71 yr, ship 4.23 yr
the trip in the ship's frame: 1.90 ly at 0.9 c takes 2.12 yr

The sum lands on the closed forms L/β and L/(γβ), with L = 4.37 ly. The crew gets the same 2.12 years another way: in their frame the distance to Alpha Centauri is shorter by γ, 1.90 ly.

While the twins fly apart at constant speed, each measures the other's clock to run slow. So who is younger? The rule dτ = dt/γ holds in an inertial frame. Earth stays in one for the whole trip, so Earth's count settles it: 9.71 years divided by γ is 4.23, four ship years against nine Earth years.

Show code
fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(t, x, color=INK)
ax.axhline(0, color=INK, lw=1)
marks = np.arange(1, int(tau[-1]) + 1)              # full years on the ship's clock
t_marks = np.interp(marks, tau, t)
ax.plot(t_marks, np.interp(t_marks, t, x), "o", ms=6, color=ACCENT, zorder=3)
for k, tm in zip(marks, t_marks):
    out = tm < t[-1] / 2                            # labels outside the tent; the last one above, clear of "home"
    last = k == marks[-1]
    ax.annotate(f"ship {k} yr" if k == 1 else f"{k} yr", (tm, np.interp(tm, t, x)),
                xytext=(0, 10) if last else (-7 if out else 7, 3), textcoords="offset points",
                ha="center" if last else "right" if out else "left", va="bottom", color=ACCENT)
earth_years = np.arange(1, int(t[-1]) + 1)
ax.plot(earth_years, np.zeros_like(earth_years), "o", ms=6, color=INK, zorder=3, clip_on=False)
ax.annotate("Earth years", (earth_years[4], 0), xytext=(0, 8), textcoords="offset points", ha="center", color=INK)
ax.annotate(f"home: Earth {t[-1]:.2f} yr, ship {tau[-1]:.2f} yr", (t[-1], 0), xytext=(10.2, 4.3), ha="right",
            va="bottom", color=INK, arrowprops=dict(arrowstyle="->", color=MUTED, lw=1, relpos=(0.95, 0), shrinkB=4))
ax.set(xlabel="Earth time t / yr", ylabel="distance from Earth / ly", xlim=(0, 10.2), ylim=(0, 4.9))
plt.show()
The trip in Earth's frame: distance from Earth in ly against Earth time in yr. The ship's path rises to 4.37 ly and falls back to zero at 9.71 yr; red dots mark each full ship year, dark dots each Earth year: four ship years against nine Earth years.

The ship does not stay in one. At the turn-around it switches from the outbound frame to the inbound one, and its passengers feel the switch while Earth feels nothing. That is the asymmetry. The traveler's leg-by-leg account needs the relativity of simultaneity and arrives at the same 4.23 years; Taylor and Wheeler work it through.

How the game computes it

The state. Everything that changes lives in one object, in years, light years, and c = 1. t and tau are the two clocks, x the distance from Earth, heading +1 outbound and −1 homebound, warp the Earth time per second of play. {...DEFAULTS} makes a copy, as dict(DEFAULTS) does in Python:

const DEFAULTS = {beta: 0.01, warp: WARP_MIN, t: 0, tau: 0, x: 0, heading: 1, running: false, arrived: false};
const state = {...DEFAULTS};

One tick. Once per screen refresh, about 60 times a second, the browser calls frame through requestAnimationFrame. frame hands the real time since the last call to tick, which caps it at 50 ms, so that a background tab does not throw the ship forward, and multiplies it by the warp. A step that would overshoot the goal is shortened to land on it, so the clocks stop at exactly 4.86 and 2.12. The last three lines of tick are the clock section's sum, and a ? b : c is Python's b if a else c:

function tick(dt) {
  if (!state.running) return;
  let step = Math.min(dt, 0.05) * state.warp;          // Earth years; the cap stops jumps
  const goal = state.heading > 0 ? DATA.distance_ly : 0;
  const left = Math.abs(goal - state.x);
  if (state.beta * step >= left) {                      // land exactly on the goal
    step = left / state.beta;
    state.running = false;
    state.arrived = true;
  }
  state.t += step;
  state.tau += step * Math.sqrt(1 - state.beta ** 2);   // dτ = dt / γ
  state.x = state.arrived ? goal : state.x + state.heading * state.beta * step;
}

From the notebook into the game. The notebook computes the sky once and writes it into the game file as JSON. The stars come from the Yale Bright Star Catalogue, 5th revised edition (Hoffleit and Warren 1991), the file catalog.gz of catalog V/50 at the CDS in Strasbourg, 560 kB, pinned by its SHA-256. Its 9,094 distant stars go in with their temperatures, followed by the Sun, seven planets, Alpha Centauri, and a table of 121 blackbody colors. The script parses them from a <script type="application/json" id="data"> element; nothing is fetched, so the file runs from disk.

Aberration, color, and brightness, every frame. For a star in direction s and the direction of flight n, μ = s·n is the cos θ of the earlier sections, D = γ(1 + βμ), and the direction seen on the ship is s′ = (s + ((γ − 1)μ + γβ) n)/D. Taking the dot product with n gives back the formula for cos θ′:

function aberrate(sx, sy, sz, n, beta, gamma, out) {
  const mu = sx * n[0] + sy * n[1] + sz * n[2];        // cos θ
  const D = gamma * (1 + beta * mu);
  const k = ((gamma - 1) * mu + gamma * beta) / D;
  out[0] = sx / D + k * n[0];                           // s′ = (s + ((γ − 1)μ + γβ) n) / D
  out[1] = sy / D + k * n[1];
  out[2] = sz / D + k * n[2];
  return D;
}

D is both the denominator of the aberration and the Doppler factor, so one number moves the star and colors it, from the table at T·D. For brightness the game keeps the astronomer's magnitude, V in the catalog, seen from Earth at rest. A star ten times brighter has a magnitude 2.5 smaller, so the gain D² lowers it by 2.5 log₁₀ D² = 5 log₁₀ D:

    const m = V[i] - 5 * Math.log10(D);                // each star's light grows by D²

The Sun, planets, and Alpha Centauri get s from their position relative to the ship and brightness from their distance.

The windows. Each direction goes to the window it faces most and is projected as in a pinhole camera. The browser's Canvas 2D draws the stars, no library, the reddest first, so the bluer of two overlapping stars lies on top.

function project(s, b) {
  const a = dot(s, b.n), c = dot(s, b.r);
  let k;                                                // 0 port, 1 forward, 2 starboard, 3 aft
  if (Math.abs(a) >= Math.abs(c)) k = a >= 0 ? 1 : 3;   // more along the flight: forward or aft
  else k = c >= 0 ? 2 : 0;                              // more sideways: starboard or port
  const w = b.windows[k], depth = dot(s, w.axis);
  const x = dot(s, w.right) / depth, y = dot(s, b.u) / depth;
  return Math.abs(y) > 1 ? null : {k, x, y};
}

The cell that writes the game. It rounds the data to the digits the game needs and sorts the keys, so that two runs write the same bytes, and it writes </ as <\/, so that nothing in the data can close the script element early. The page around the game is a template string in the hidden cell before it.

Show code
# The page around the game: style with the site's light and dark colors, the clock panel, the effect
# lines, and the controls. game.js fills in every number; the notebook fills in DATA and SCRIPT.
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flight to Alpha Centauri at up to 0.999999 c</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98; }
@media (prefers-color-scheme: dark) {
  :root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab; }
}
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--fg); font: clamp(14px, 1.95vw, 26px)/1.35 system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { display: flex; flex-direction: column; }
canvas { display: block; width: 100%; flex: none; }
#panel { flex: 1; min-height: 0; display: grid; grid-template-columns: auto 1fr; grid-template-areas: "clocks effects" "clocks controls";
  align-content: center; gap: 0.6em 2em; padding: 0 0.8em 0.4em; }
.clocks { grid-area: clocks; } .effects { grid-area: effects; } .controls { grid-area: controls; }
.clock { font-size: 1.9em; line-height: 1.2; font-variant-numeric: tabular-nums; }
#earth { color: var(--ink); } #ship, .fx b { color: var(--accent); font-weight: normal; }
.fx { margin: 0 0 0.2em; } #fx-short { display: none; margin: 0; }
.swatch { display: inline-block; width: 0.8em; height: 0.8em; border-radius: 50%; vertical-align: -0.05em; }
.controls { display: grid; gap: 0.3em; }
.row { display: flex; align-items: center; gap: 0.6em; } .row label { min-width: 8.5em; }
.track { order: 1; flex: 1; display: flex; align-items: center; gap: 0.4em; } .val { order: 2; width: 5.5em; }
input { flex: 1; min-width: 60px; margin: 0; accent-color: var(--second); }
.buttons { display: flex; gap: 0.5em; margin-top: 0.2em; }
button { font: inherit; color: var(--bg); background: var(--second); border: 0; border-radius: 4px; padding: 0.2em 0.8em; } button:disabled { opacity: 0.4; }
button.step { padding: 0; width: 1.8em; }
button, input { touch-action: manipulation; }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } input[type=range] { height: 44px; } }
@media (max-height: 420px) and (min-width: 600px) {
  #panel { align-content: start; gap: 0.2em 1.5em; padding-top: 0.3em; }
  .clock { font-size: 1.3em; } .fx, #status { display: none; } #fx-short { display: block; }
}
@media (max-width: 599px) {
  #panel { grid-template-columns: 1fr; grid-template-areas: "clocks" "effects" "controls"; align-content: start; gap: 0; padding: 2px 6px; }
  .clock { font-size: 1em; display: inline; margin-right: 12px; } #info, #status, .fx { display: none; } #fx-short { display: block; }
  .controls { gap: 0; } .buttons { margin-top: 4px; }
  .row { flex-wrap: wrap; row-gap: 0; } .row label { flex: 1; min-width: 0; } .val { order: 1; width: auto; text-align: right; }
  .track { order: 2; flex-basis: 100%; }
}
</style>
</head>
<body>
<canvas id="sky"></canvas>
<div id="panel">
  <div class="clocks">
    <div class="clock" id="earth"></div> <div class="clock" id="ship"></div>
    <div id="info"></div><div id="status"></div>
  </div>
  <div class="effects">
    <p class="fx">stars from beside the ship now at <b id="fx-ring"></b></p>
    <p class="fx">a 5,800 K star: <span class="swatch" id="sw-hot"></span> <b id="fx-hot"></b> ahead,
      <span class="swatch" id="sw-cold"></span> <b id="fx-cold"></b> behind</p>
    <p class="fx">sky brightness <b id="fx-bright"></b> ahead, <b id="fx-dark"></b> behind</p>
    <p id="fx-short"></p>
  </div>
  <div class="controls">
    <div class="row"><label for="speed">Speed v/c</label><span class="val" id="speed-val"></span>
      <div class="track"><button class="step" id="slower" aria-label="Slower">−</button><input id="speed" type="range" step="0.001"><button class="step" id="faster" aria-label="Faster">+</button></div></div>
    <div class="row"><label for="warp">Earth time per s</label><span class="val" id="warp-val"></span>
      <div class="track"><input id="warp" type="range" min="0" max="1" step="0.001"></div></div>
    <div class="buttons"><button id="play">Play</button> <button id="reset">Reset</button> <button id="turn">Turn around</button></div>
  </div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
def rounded(values, digits):
    return [round(float(v), digits) for v in np.ravel(values)]

sky_data = {
    "source": "Yale Bright Star Catalogue, 5th revised ed. (Hoffleit and Warren 1991), CDS V/50",
    "departure": "2027-01-01 12:00 TDB",
    "distance_ly": L_LY,
    "stars": {"xyz": rounded(stars["xyz"], 4), "v": rounded(stars["v"], 2),
              "temp": [int(round(T, -1)) for T in stars["temp"]]},
    "near": [dict(name=o["name"], label=o["label"], pos=rounded(o["pos"], 10), v=round(float(o["v"]), 2),
                  temp=int(round(o["temp"], -1))) for o in near],
    "colors": COLORS,
}
# sorted keys and fixed rounding: two runs write the same bytes; "<\/" keeps "</" out of the script element
data = json.dumps(sky_data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
script = Path("minigame/flight/game.js").read_text(encoding="utf-8")
html = TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script)
Path("assets").mkdir(exist_ok=True)
Path("assets/flight.html").write_text(html, encoding="utf-8", newline="\n")
size = Path("assets/flight.html").stat().st_size
print(f"assets/flight.html: {len(sky_data['stars']['v'])} stars, {len(near)} near objects, {size / 1024:.0f} kB")
assets/flight.html: 9094 stars, 9 near objects, 310 kB

Variations

  • Constant 1 g with a flip. Replace the constant β by a rapidity η that grows by (g/c)·Δτ each tick, with β = tanh η and the thrust reversed at half way. The ship's clock reads 3.58 years at Alpha Centauri and Earth's 6.00, at a top speed of 0.952 c.
  • What the eye sees. Replace each star's gain D² by the visible-light factor of the brightness section, Planck's law at 555 nm for the star's own temperature. The 5,800 K star ahead then gains 2.5 instead of 19, and the hottest stars fade into the ultraviolet.
  • Rain instead of light. In aberrate in game.js, set s′ to s + βn divided by its length, the rule of the rain on the windshield without relativity, and keep D for the colors. At 0.9 c the ring opens to 48.0° and the cap inside it to 16.6 % of the sky, against 25.8° and 5.0 % with relativity.

Further reading

  • E. F. Taylor and J. A. Wheeler, Spacetime Physics, 2nd ed. (Freeman, 1992), for time dilation and the traveler's own account of the twin paradox.
  • J. M. McKinley and P. Doherty, "In search of the starbow: the appearance of the starfield from a relativistic spaceship", Am. J. Phys. 47, 309 (1979).
  • U. Kraus, "Brightness and color of rapidly moving objects: the visual appearance of a large sphere revisited", Am. J. Phys. 68, 56 (2000), for the factors D² and D⁴ done carefully.
  • D. Hoffleit and W. H. Warren Jr., The Bright Star Catalogue, 5th revised ed. (1991), CDS V/50; F. J. Ballesteros, EPL 97, 34008 (2012), for temperatures from colors; C. Wyman, P.-P. Sloan, and P. Shirley, JCGT 2(2), 1 (2013), for the color matching functions.
  • The Canvas API on MDN, for the drawing calls in game.js.
  • Related tutorials on this site: Ray tracing a black hole with solve_ivp and Compute Christoffel symbols and the curvature of a metric with SymPy for general relativity, Matplotlib animation with FuncAnimation, Interactive figures with Plotly, and Colormaps, for why the stars wear blackbody colors and not a colormap.
  • Download the notebook. It was executed with the library versions in the header.
  • Download the game, one HTML file of 310 kB that runs offline in any browser.