Springs & Oscillation
Hooke's law, chained and damped.
Beginner / integration / oscillation / conservation
A chain of masses between two walls obeys Hooke's law at every connection. Without damping the total energy must not drift - the metric verifies it - and with damping you can watch exactly how fast the motion dies. The trace plots the first mass through its normal modes.
Tagged: Hooke's law, normal modes, damping, energy conservation
Live simulation
Damping zero must hold total energy flat - the drift metric is the proof. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Damping zero must hold total energy flat - the drift metric is the proof.
Mathematics
Hooke's law says a stretched spring pulls with force proportional to the stretch: F = -k x. Chain that law between fixed walls and masses and you get coupled oscillators whose normal modes superpose - the whole motion from one force rule applied per connection.
F_i = k (x_{i+1} - x_i - L) - k (x_i - x_{i-1} - L)
semi-implicit Euler: v <- v + (F/m - b v) h; x <- x + v h
total energy E = sum 1/2 m v^2 + 1/2 k (stretch)^2
Semi-implicit (symplectic) Euler is chosen deliberately: it keeps the total energy bounded instead of steadily growing like forward Euler would. The drift metric proves it - zero drift with damping off, monotone decay with damping on. Both behaviors are properties of the integrator-plus-physics pair, and both are checkable.
How it works
The chain integrates with four substeps per tick for stability at high k. The trace plots the first mass over time; set masses to 1 for a pure sinusoid, raise k and watch the frequency climb as sqrt(k/m).
Walls count as springs too: the end masses feel one wall and one neighbor, so the chain is a genuine coupled system, not independent oscillators.
Implementation
src/engine/experiments/springs.ts · 161 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 | * A chain of masses and springs (fixed ends), integrated semi-implicitly. |
| 6 | * Undamped, the total energy must hold still - the metrics keep score. |
| 7 | */ |
| 8 | export class SpringChainExperiment extends BaseExperiment { |
| 9 | readonly id = "springs"; |
| 10 | |
| 11 | private count = 3; |
| 12 | private x = new Float64Array(0); |
| 13 | private v = new Float64Array(0); |
| 14 | private restLen = 1; |
| 15 | private time = 0; |
| 16 | private energy0 = 1; |
| 17 | private energy = 1; |
| 18 | private trace: { t: number; x: number }[] = []; |
| 19 | |
| 20 | protected params(): ParameterDef[] { |
| 21 | return [ |
| 22 | { key: "masses", label: "Masses in chain", min: 1, max: 5, step: 1, defaultValue: 3 }, |
| 23 | { key: "k", label: "Spring constant k", min: 5, max: 120, step: 5, defaultValue: 60 }, |
| 24 | { key: "damping", label: "Damping", min: 0, max: 2, step: 0.05, defaultValue: 0 }, |
| 25 | { key: "amplitude", label: "Initial stretch", min: 0.1, max: 2, step: 0.1, defaultValue: 1 }, |
| 26 | ]; |
| 27 | } |
| 28 | |
| 29 | protected onReset(): void { |
| 30 | this.count = this.num("masses"); |
| 31 | this.x = new Float64Array(this.count); |
| 32 | this.v = new Float64Array(this.count); |
| 33 | // Stretch the middle mass(es); ends of the chain hang from walls. |
| 34 | const amplitude = this.num("amplitude"); |
| 35 | for (let i = 0; i < this.count; i++) { |
| 36 | this.x[i] = amplitude * Math.sin((Math.PI * (i + 1)) / (this.count + 1)); |
| 37 | } |
| 38 | this.restLen = 1; |
| 39 | this.time = 0; |
| 40 | this.energy0 = this.totalEnergy(); |
| 41 | this.trace = []; |
| 42 | } |
| 43 | |
| 44 | protected onParameterChange(key: string): void { |
| 45 | if (key !== "damping") this.reset(); |
| 46 | } |
| 47 | |
| 48 | private totalEnergy(): number { |
| 49 | const m = 1; |
| 50 | const k = this.num("k"); |
| 51 | let ke = 0; |
| 52 | let pe = 0; |
| 53 | for (let i = 0; i < this.count; i++) ke += 0.5 * m * this.v[i] * this.v[i]; |
| 54 | let prev = 0; |
| 55 | for (let i = 0; i < this.count; i++) { |
| 56 | const stretch = this.x[i] - prev; |
| 57 | pe += 0.5 * k * stretch * stretch; |
| 58 | prev = this.x[i]; |
| 59 | } |
| 60 | pe += 0.5 * k * (0 - prev) ** 2; |
| 61 | return ke + pe; |
| 62 | } |
| 63 | |
| 64 | protected onUpdate(dt: number): void { |
| 65 | const k = this.num("k"); |
| 66 | const damping = this.num("damping"); |
| 67 | const m = 1; |
| 68 | const substeps = 24; |
| 69 | const h = dt / substeps; |
| 70 | for (let s = 0; s < substeps; s++) { |
| 71 | // Coupled oscillators: forces from neighbors with fixed walls (u = 0). |
| 72 | for (let i = 0; i < this.count; i++) { |
| 73 | const leftU = i === 0 ? 0 : this.x[i - 1]; |
| 74 | const rightU = i === this.count - 1 ? 0 : this.x[i + 1]; |
| 75 | const fNet = k * (leftU - this.x[i]) + k * (rightU - this.x[i]); |
| 76 | this.v[i] += (fNet / m - damping * this.v[i]) * h; |
| 77 | } |
| 78 | for (let i = 0; i < this.count; i++) this.x[i] += this.v[i] * h; |
| 79 | } |
| 80 | this.time += dt; |
| 81 | this.energy = this.totalEnergy(); |
| 82 | this.trace.push({ t: this.time, x: this.x[0] }); |
| 83 | if (this.trace.length > 700) this.trace.shift(); |
| 84 | } |
| 85 | |
| 86 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 87 | ctx.fillStyle = theme.bg; |
| 88 | ctx.fillRect(0, 0, this.width, this.height); |
| 89 | // Physical chain across the top half |
| 90 | const span = this.width - 120; |
| 91 | const originX = 60; |
| 92 | const chainY = this.height * 0.3; |
| 93 | ctx.strokeStyle = theme.line; |
| 94 | ctx.beginPath(); |
| 95 | ctx.moveTo(originX - 20, chainY); |
| 96 | ctx.lineTo(originX, chainY); |
| 97 | ctx.moveTo(originX + span, chainY); |
| 98 | ctx.lineTo(originX + span + 20, chainY); |
| 99 | ctx.stroke(); |
| 100 | ctx.strokeStyle = theme.fgSecondary; |
| 101 | ctx.beginPath(); |
| 102 | let prevX = originX; |
| 103 | let prevY = chainY; |
| 104 | for (let i = 0; i < this.count; i++) { |
| 105 | const px = originX + ((i + 1) * span) / (this.count + 1) + this.x[i] * 26; |
| 106 | ctx.moveTo(prevX, prevY); |
| 107 | ctx.lineTo(px, chainY); |
| 108 | prevX = px; |
| 109 | prevY = chainY; |
| 110 | } |
| 111 | ctx.lineTo(originX + span, chainY); |
| 112 | ctx.stroke(); |
| 113 | for (let i = 0; i < this.count; i++) { |
| 114 | const px = originX + ((i + 1) * span) / (this.count + 1) + this.x[i] * 26; |
| 115 | ctx.fillStyle = theme.accent; |
| 116 | ctx.beginPath(); |
| 117 | ctx.arc(px, chainY, 8, 0, Math.PI * 2); |
| 118 | ctx.fill(); |
| 119 | } |
| 120 | // Phase / time trace of the first mass |
| 121 | const traceBase = this.height - 26; |
| 122 | const traceH = this.height * 0.34; |
| 123 | let lo = -2; |
| 124 | let hi = 2; |
| 125 | for (const point of this.trace) { |
| 126 | lo = Math.min(lo, point.x); |
| 127 | hi = Math.max(hi, point.x); |
| 128 | } |
| 129 | const span2 = Math.max(0.4, hi - lo); |
| 130 | ctx.strokeStyle = theme.viz[1]; |
| 131 | ctx.lineWidth = 1.5; |
| 132 | ctx.beginPath(); |
| 133 | for (let i = 0; i < this.trace.length; i++) { |
| 134 | const x = (i / 700) * this.width; |
| 135 | const y = traceBase - ((this.trace[i].x - lo) / span2) * traceH; |
| 136 | if (i === 0) ctx.moveTo(x, y); |
| 137 | else ctx.lineTo(x, y); |
| 138 | } |
| 139 | ctx.stroke(); |
| 140 | ctx.lineWidth = 1; |
| 141 | } |
| 142 | |
| 143 | getMetrics() { |
| 144 | const drift = this.energy0 > 0 ? ((this.energy - this.energy0) / this.energy0) * 100 : 0; |
| 145 | return { |
| 146 | masses: this.count, |
| 147 | time: `${this.time.toFixed(1)} s`, |
| 148 | totalEnergy: this.energy.toFixed(2), |
| 149 | energyDrift: `${drift >= 0 ? "+" : ""}${drift.toFixed(2)} %`, |
| 150 | }; |
| 151 | } |
| 152 | |
| 153 | describe(): string { |
| 154 | return `${this.count} masses on a spring chain: total energy ${this.energy.toFixed(2)} against the launch value ${this.energy0.toFixed(2)} - damping bleeds it, an undamped chain must hold it.`; |
| 155 | } |
| 156 | |
| 157 | entities(): number { |
| 158 | return this.count; |
| 159 | } |
| 160 | } |
| 161 | |
Performance
- Time per tick
- O(masses * substeps) per tick
- Space
- Two float arrays plus the trace
- Notes
- Damping 0 versus 0.5 on the same ICs is the cleanest energy-conservation demo in the catalog.
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.