Skip to content

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.

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

1import { BaseExperiment } from "../core/base-experiment";
2import 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 */
9export 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.