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. Keyboard: Space runs or pauses, . steps, r resets.
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
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | const 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 | */ |
| 11 | export 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.