Skip to content

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.

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

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