Skip to content

Elastic Collisions

Momentum conserved, energy accounted for.

Beginner / particles / collision / conservation

Hard disks bounce off each other and the walls with impulse-based resolution at restitution 1: kinetic energy and momentum should survive every collision. The metrics track both, so the physics is audited in real time - and you can sling new disks in with a drag.

Tagged: impulse, hard disks, kinetic energy, momentum

Live simulation

Press, drag, release to sling a new disk in with the drag velocity.

Speed

Press, drag, release to sling a new disk in with the drag velocity.

Mathematics

Two disks collide elastically when the impulse along their contact normal reflects the relative normal velocity with restitution 1. Momentum is conserved by construction; kinetic energy is conserved because e = 1 and the positional separation is orthogonal to the impulse.

j = −(1 + e) (v_rel · n) / (1/m₁ + 1/m₂), e = 1

v₁ ← v₁ + (j/m₁) n; v₂ ← v₂ − (j/m₂) n

KE = Σ ½ m v², p = Σ m v

The impulse is only applied when bodies are approaching (v_rel · n < 0), which prevents resting-contact jitter. Energy drift in the metrics comes from two honest sources: float32 state and the positional overlap separation - watching how small it stays is the point of showing it.

How it works

Disks integrate ballistically, bounce off the four walls, and resolve pairwise with a combined radius check. Overlap is split half-and-half along the normal before the impulse so disks never sink into each other. The pointer slingshots new disks in: press, drag, release, and the drag vector becomes a velocity.

Kinetic energy and total momentum are recomputed twice a second; collisions per second come from a one-second sliding count.

Implementation

src/engine/experiments/elastic-collisions.ts · 222 lines · the module running above

TypeScript · fixed-timestep engine

1import { BaseExperiment } from "../core/base-experiment";
2import type { ParameterDef, PointerState, ThemeColors } from "../core/types";
3
4const CAPACITY = 200;
5
6/**
7 * 2-D elastic collisions: hard disks in a box with impulse-based pairwise
8 * resolution (coefficient of restitution 1). Kinetic energy and momentum are
9 * measured live, so "elastic" is a claim the metrics either support or don't.
10 */
11export class ElasticCollisionsExperiment extends BaseExperiment {
12 readonly id = "elastic-collisions";
13
14 private count = 0;
15 private x = new Float32Array(CAPACITY);
16 private y = new Float32Array(CAPACITY);
17 private vx = new Float32Array(CAPACITY);
18 private vy = new Float32Array(CAPACITY);
19 private r = new Float32Array(CAPACITY);
20 private mass = new Float32Array(CAPACITY);
21 private collisionsThisSecond = 0;
22 private collisionWindow = 0;
23 private windowStart = 0;
24 private collisionsPerSecond = 0;
25 private ke0 = 1;
26 private ke = 1;
27 private momentum = 0;
28 private time = 0;
29 // Slingshot spawn: press, drag, release.
30 private dragStart = { x: 0, y: 0 };
31 private dragEnd = { x: 0, y: 0 };
32 private dragging = false;
33
34 protected params(): ParameterDef[] {
35 return [
36 { key: "count", label: "Initial disks", min: 10, max: 120, step: 10, defaultValue: 60 },
37 { key: "radius", label: "Disk radius", min: 4, max: 12, step: 1, defaultValue: 7, unit: "px" },
38 { key: "speed", label: "Initial speed", min: 20, max: 200, step: 10, defaultValue: 90, unit: "px/s" },
39 ];
40 }
41
42 protected onReset(): void {
43 this.count = Math.min(CAPACITY, this.num("count"));
44 const radius = this.num("radius");
45 for (let i = 0; i < this.count; i++) {
46 // Rejection-free placement: random position, overlap resolved by the
47 // first ticks of motion (hard disks settle out quickly).
48 this.x[i] = radius + this.rng() * (this.width - 2 * radius);
49 this.y[i] = radius + this.rng() * (this.height - 2 * radius);
50 this.r[i] = radius;
51 this.mass[i] = radius * radius;
52 const angle = this.rng() * Math.PI * 2;
53 const speed = this.num("speed") * (0.6 + this.rng() * 0.8);
54 this.vx[i] = Math.cos(angle) * speed;
55 this.vy[i] = Math.sin(angle) * speed;
56 }
57 this.time = 0;
58 this.ke0 = this.kineticEnergy();
59 this.ke = this.ke0;
60 this.windowStart = this.time;
61 this.collisionsThisSecond = 0;
62 }
63
64 protected onParameterChange(key: string): void {
65 if (key !== "radius") this.reset();
66 }
67
68 resize(width: number, height: number): void {
69 this.width = width;
70 this.height = height;
71 this.reset();
72 }
73
74 onPointer(state: PointerState): void {
75 if (state.down && !this.dragging) {
76 this.dragging = true;
77 this.dragStart = { x: state.x, y: state.y };
78 this.dragEnd = { x: state.x, y: state.y };
79 } else if (this.dragging && state.down) {
80 this.dragEnd = { x: state.x, y: state.y };
81 } else if (this.dragging && !state.down) {
82 this.dragging = false;
83 // Slingshot: spawn a disk with velocity proportional to the drag vector.
84 if (this.count < CAPACITY) {
85 const i = this.count++;
86 const radius = this.num("radius");
87 this.x[i] = Math.min(this.width - radius, Math.max(radius, this.dragStart.x));
88 this.y[i] = Math.min(this.height - radius, Math.max(radius, this.dragStart.y));
89 this.r[i] = radius + 2;
90 this.mass[i] = this.r[i] * this.r[i];
91 this.vx[i] = (this.dragEnd.x - this.dragStart.x) * 3;
92 this.vy[i] = (this.dragEnd.y - this.dragStart.y) * 3;
93 }
94 }
95 }
96
97 private kineticEnergy(): number {
98 let ke = 0;
99 for (let i = 0; i < this.count; i++) {
100 ke += 0.5 * this.mass[i] * (this.vx[i] * this.vx[i] + this.vy[i] * this.vy[i]);
101 }
102 return ke;
103 }
104
105 protected onUpdate(dt: number): void {
106 for (let i = 0; i < this.count; i++) {
107 this.x[i] += this.vx[i] * dt;
108 this.y[i] += this.vy[i] * dt;
109 // Walls
110 if (this.x[i] < this.r[i]) {
111 this.x[i] = this.r[i];
112 this.vx[i] = Math.abs(this.vx[i]);
113 this.collisionsThisSecond += 1;
114 } else if (this.x[i] > this.width - this.r[i]) {
115 this.x[i] = this.width - this.r[i];
116 this.vx[i] = -Math.abs(this.vx[i]);
117 this.collisionsThisSecond += 1;
118 }
119 if (this.y[i] < this.r[i]) {
120 this.y[i] = this.r[i];
121 this.vy[i] = Math.abs(this.vy[i]);
122 this.collisionsThisSecond += 1;
123 } else if (this.y[i] > this.height - this.r[i]) {
124 this.y[i] = this.height - this.r[i];
125 this.vy[i] = -Math.abs(this.vy[i]);
126 this.collisionsThisSecond += 1;
127 }
128 }
129 // Pairwise impulses, O(n^2); fine at the 200-disk capacity.
130 for (let i = 0; i < this.count; i++) {
131 for (let j = i + 1; j < this.count; j++) {
132 const dx = this.x[j] - this.x[i];
133 const dy = this.y[j] - this.y[i];
134 const rr = this.r[i] + this.r[j];
135 const d2 = dx * dx + dy * dy;
136 if (d2 >= rr * rr || d2 === 0) continue;
137 const d = Math.sqrt(d2);
138 const nx = dx / d;
139 const ny = dy / d;
140 // Separate the overlap first (half each along the normal).
141 const overlap = rr - d;
142 this.x[i] -= nx * overlap * 0.5;
143 this.y[i] -= ny * overlap * 0.5;
144 this.x[j] += nx * overlap * 0.5;
145 this.y[j] += ny * overlap * 0.5;
146 // Impulse for e = 1 along the normal.
147 const vRelN = (this.vx[j] - this.vx[i]) * nx + (this.vy[j] - this.vy[i]) * ny;
148 if (vRelN < 0) {
149 const invMassI = 1 / this.mass[i];
150 const invMassJ = 1 / this.mass[j];
151 const jImpulse = (-(1 + 1) * vRelN) / (invMassI + invMassJ);
152 this.vx[i] -= jImpulse * invMassI * nx;
153 this.vy[i] -= jImpulse * invMassI * ny;
154 this.vx[j] += jImpulse * invMassJ * nx;
155 this.vy[j] += jImpulse * invMassJ * ny;
156 this.collisionsThisSecond += 1;
157 }
158 }
159 }
160 this.time += dt;
161 this.collisionWindow += dt;
162 if (this.collisionWindow >= 1) {
163 this.collisionsPerSecond = this.collisionsThisSecond / this.collisionWindow;
164 this.collisionsThisSecond = 0;
165 this.collisionWindow = 0;
166 }
167 if (Math.floor(this.time * 2) !== Math.floor((this.time - dt) * 2)) {
168 this.ke = this.kineticEnergy();
169 let px = 0;
170 let py = 0;
171 for (let i = 0; i < this.count; i++) {
172 px += this.mass[i] * this.vx[i];
173 py += this.mass[i] * this.vy[i];
174 }
175 this.momentum = Math.hypot(px, py);
176 }
177 }
178
179 render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void {
180 ctx.fillStyle = theme.bg;
181 ctx.fillRect(0, 0, this.width, this.height);
182 ctx.strokeStyle = theme.line;
183 ctx.strokeRect(0.5, 0.5, this.width - 1, this.height - 1);
184 // Slingshot aim line
185 if (this.dragging) {
186 ctx.strokeStyle = theme.accentStrong;
187 ctx.setLineDash([4, 4]);
188 ctx.beginPath();
189 ctx.moveTo(this.dragStart.x, this.dragStart.y);
190 ctx.lineTo(this.dragEnd.x, this.dragEnd.y);
191 ctx.stroke();
192 ctx.setLineDash([]);
193 }
194 for (let i = 0; i < this.count; i++) {
195 const speed = Math.hypot(this.vx[i], this.vy[i]);
196 ctx.fillStyle =
197 speed > this.num("speed") * 1.6 ? theme.accent : i === this.count - 1 ? theme.viz[1] : theme.fg;
198 ctx.beginPath();
199 ctx.arc(this.x[i], this.y[i], this.r[i], 0, Math.PI * 2);
200 ctx.fill();
201 }
202 }
203
204 getMetrics() {
205 const drift = this.ke0 !== 0 ? ((this.ke - this.ke0) / Math.abs(this.ke0)) * 100 : 0;
206 return {
207 bodies: this.count,
208 collisionsPerSecond: this.collisionsPerSecond.toFixed(0),
209 energyDrift: `${drift >= 0 ? "+" : ""}${drift.toFixed(3)} %`,
210 momentum: this.momentum.toFixed(0),
211 };
212 }
213
214 describe(): string {
215 return `${this.count} hard disks colliding elastically; kinetic energy drifted ${(this.ke0 !== 0 ? ((this.ke - this.ke0) / Math.abs(this.ke0)) * 100 : 0).toFixed(2)}% from launch. Drag to sling a new disk in.`;
216 }
217
218 entities(): number {
219 return this.count;
220 }
221}
222

Performance

Time per tick
O(n²) pair checks per tick - comfortable at the 200-disk capacity
Space
Six floats per disk
Notes
Mass scales with radius squared, so the slingshot disks genuinely shove smaller ones aside.

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.