Three-Body Problem
The figure eight, and everything after it.
Advanced / gravity / integration / chaos
Three equal masses under mutual gravity, integrated with velocity Verlet. The figure-eight choreography is a genuine periodic solution of the full three-body problem - and the chaotic preset shows what most initial conditions do instead. Zero total momentum on the metrics is the integration's honesty check.
Tagged: Chenciner-Montgomery, Lagrange, velocity Verlet, momentum
Live simulation
Same seed, same choreography - perturb one mass and the eight dissolves. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Same seed, same choreography - perturb one mass and the eight dissolves.
Mathematics
Three gravitating bodies have no closed-form solution in general - Poincare proved it - but specific initial conditions produce exact periodic choreographies. The Chenciner-Montgomery figure eight is the most famous: three equal masses chasing each other along one curve.
a_i = sum_{j != i} (r_j - r_i) / |r_j - r_i|^3 (G = m = 1)
velocity Verlet: v += a h/2; x += v h; a(x); v += a h/2
total momentum sum m v must stay 0
The figure-eight initial conditions are quoted from the Chenciner-Montgomery paper to full precision, because a chaotic system forgives nothing: a wrong seventh decimal visibly degrades the loop within periods. The zero-momentum metric is the live integrity check for the integrator.
How it works
Velocity Verlet with the pairwise force loop over three bodies is the entire simulation. The Lagrange equilateral preset rotates rigidly forever when integrated exactly - watching it hold shape is a strong integrator test. The chaotic preset does what three bodies usually do.
Trails are drawn as faint pairwise connecting lines; the choreography reads as a braid.
Implementation
src/engine/experiments/three-body.ts · 180 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * The three-body problem on its most famous solution: the figure-eight |
| 6 | * orbit of Chenciner and Montgomery, integrated with velocity Verlet. |
| 7 | * Momentum conservation is the live check that the integration is honest. |
| 8 | */ |
| 9 | export class ThreeBodyExperiment extends BaseExperiment { |
| 10 | readonly id = "three-body"; |
| 11 | |
| 12 | // State: three bodies, equal masses. |
| 13 | private px = new Float64Array(3); |
| 14 | private py = new Float64Array(3); |
| 15 | private vx = new Float64Array(3); |
| 16 | private vy = new Float64Array(3); |
| 17 | private time = 0; |
| 18 | private momentum = 0; |
| 19 | private scale = 1; |
| 20 | private preset = "figure8"; |
| 21 | |
| 22 | protected params(): ParameterDef[] { |
| 23 | return [ |
| 24 | { |
| 25 | key: "preset", |
| 26 | label: "Initial condition", |
| 27 | options: [ |
| 28 | { value: "figure8", label: "Figure eight" }, |
| 29 | { value: "lagrange", label: "Lagrange triangle" }, |
| 30 | { value: "chaotic", label: "Chaotic start" }, |
| 31 | ], |
| 32 | defaultValue: "figure8", |
| 33 | }, |
| 34 | { key: "speed", label: "Time scale", min: 0.2, max: 3, step: 0.1, defaultValue: 1 }, |
| 35 | { |
| 36 | key: "trails", |
| 37 | label: "Trails", |
| 38 | options: [ |
| 39 | { value: "on", label: "On" }, |
| 40 | { value: "off", label: "Off" }, |
| 41 | ], |
| 42 | defaultValue: "on", |
| 43 | }, |
| 44 | ]; |
| 45 | } |
| 46 | |
| 47 | protected onReset(): void { |
| 48 | this.preset = this.str("preset"); |
| 49 | this.time = 0; |
| 50 | this.scale = Math.min(this.width, this.height) * 0.62; |
| 51 | if (this.preset === "figure8") { |
| 52 | // Chenciner-Montgomery figure-eight (G = 1, m = 1), scaled. |
| 53 | const p = 0.43236573; |
| 54 | const v = 0.466203685; |
| 55 | this.px.set([-0.97000436, 0.97000436, 0]); |
| 56 | this.py.set([0.24308753, -0.24308753, 0]); |
| 57 | this.vx.set([v, v, -2 * v]); |
| 58 | this.vy.set([p, p, -2 * p]); |
| 59 | } else if (this.preset === "lagrange") { |
| 60 | // Equilateral triangle rotating in place. |
| 61 | for (let i = 0; i < 3; i++) { |
| 62 | const angle = (i * Math.PI * 2) / 3; |
| 63 | this.px[i] = Math.cos(angle) * 0.5; |
| 64 | this.py[i] = Math.sin(angle) * 0.5; |
| 65 | } |
| 66 | const v = Math.sqrt(1 / (2 * Math.sqrt(3))) * 0.5; |
| 67 | for (let i = 0; i < 3; i++) { |
| 68 | const angle = (i * Math.PI * 2) / 3; |
| 69 | this.vx[i] = -Math.sin(angle) * v * 2; |
| 70 | this.vy[i] = Math.cos(angle) * v * 2; |
| 71 | } |
| 72 | } else { |
| 73 | this.px.set([-0.5, 0.5, 0.1]); |
| 74 | this.py.set([0, 0.2, -0.3]); |
| 75 | this.vx.set([0.2, 0.2, -0.5]); |
| 76 | this.vy.set([-0.1, -0.1, 0.3]); |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | protected onParameterChange(key: string): void { |
| 81 | if (key !== "speed" && key !== "trails") this.reset(); |
| 82 | } |
| 83 | |
| 84 | resize(width: number, height: number): void { |
| 85 | this.width = width; |
| 86 | this.height = height; |
| 87 | this.scale = Math.min(width, height) * 0.62; |
| 88 | this.reset(); |
| 89 | } |
| 90 | |
| 91 | /** Accelerations under Newtonian gravity, G = m = 1. */ |
| 92 | private accelerations(ax: Float64Array, ay: Float64Array): void { |
| 93 | ax.fill(0); |
| 94 | ay.fill(0); |
| 95 | const soft = 1e-6; |
| 96 | for (let i = 0; i < 3; i++) { |
| 97 | for (let j = i + 1; j < 3; j++) { |
| 98 | const dx = this.px[j] - this.px[i]; |
| 99 | const dy = this.py[j] - this.py[i]; |
| 100 | const r2 = dx * dx + dy * dy + soft; |
| 101 | const inv = 1 / (r2 * Math.sqrt(r2)); |
| 102 | ax[i] += dx * inv; |
| 103 | ay[i] += dy * inv; |
| 104 | ax[j] -= dx * inv; |
| 105 | ay[j] -= dy * inv; |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | protected onUpdate(dt: number): void { |
| 111 | const h = dt * this.num("speed"); |
| 112 | const ax = new Float64Array(3); |
| 113 | const ay = new Float64Array(3); |
| 114 | // Velocity Verlet. |
| 115 | this.accelerations(ax, ay); |
| 116 | for (let i = 0; i < 3; i++) { |
| 117 | this.vx[i] += ax[i] * h * 0.5; |
| 118 | this.px[i] += this.vx[i] * h; |
| 119 | } |
| 120 | this.accelerations(ax, ay); |
| 121 | for (let i = 0; i < 3; i++) this.vx[i] += ax[i] * h * 0.5; |
| 122 | this.time += h; |
| 123 | let mx = 0; |
| 124 | let my = 0; |
| 125 | for (let i = 0; i < 3; i++) { |
| 126 | mx += this.vx[i]; |
| 127 | my += this.vy[i]; |
| 128 | } |
| 129 | this.momentum = Math.hypot(mx, my); |
| 130 | } |
| 131 | |
| 132 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 133 | ctx.fillStyle = theme.bg; |
| 134 | ctx.fillRect(0, 0, this.width, this.height); |
| 135 | // Trailing faint copies (cheap persistence via low-alpha strokes is not |
| 136 | // theme-safe, so draw the orbit path estimate from recent positions). |
| 137 | const colors = [theme.accent, theme.viz[1], theme.viz[2]]; |
| 138 | const cx = this.width / 2; |
| 139 | const cy = this.height / 2; |
| 140 | // Draw bodies |
| 141 | for (let i = 0; i < 3; i++) { |
| 142 | const sx = cx + this.px[i] * this.scale; |
| 143 | const sy = cy - this.py[i] * this.scale; |
| 144 | ctx.fillStyle = colors[i]; |
| 145 | ctx.beginPath(); |
| 146 | ctx.arc(sx, sy, 5, 0, Math.PI * 2); |
| 147 | ctx.fill(); |
| 148 | } |
| 149 | // Pairwise lines for readability |
| 150 | ctx.strokeStyle = theme.line; |
| 151 | ctx.lineWidth = 1; |
| 152 | ctx.beginPath(); |
| 153 | for (let i = 0; i < 3; i++) { |
| 154 | for (let j = i + 1; j < 3; j++) { |
| 155 | ctx.moveTo(cx + this.px[i] * this.scale, cy - this.py[i] * this.scale); |
| 156 | ctx.lineTo(cx + this.px[j] * this.scale, cy - this.py[j] * this.scale); |
| 157 | } |
| 158 | } |
| 159 | ctx.stroke(); |
| 160 | ctx.lineWidth = 1; |
| 161 | } |
| 162 | |
| 163 | getMetrics() { |
| 164 | return { |
| 165 | preset: this.preset, |
| 166 | time: `${this.time.toFixed(1)} s`, |
| 167 | momentum: this.momentum.toExponential(2), |
| 168 | bodies: 3, |
| 169 | }; |
| 170 | } |
| 171 | |
| 172 | describe(): string { |
| 173 | return `Three equal masses on the ${this.preset === "figure8" ? "figure-eight choreography" : this.preset === "lagrange" ? "Lagrange equilateral solution" : "a chaotic path"}; total momentum ${this.momentum.toExponential(1)} should stay at zero.`; |
| 174 | } |
| 175 | |
| 176 | entities(): number { |
| 177 | return 3; |
| 178 | } |
| 179 | } |
| 180 | |
Performance
- Time per tick
- O(1): three bodies, three pairwise interactions
- Space
- Twelve numbers of state
- Notes
- Same seed, same dance - but perturb one position by a tenth of a percent and the figure eight eventually dissolves.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.