Two masses joined by a spring. Start one moving and the other stays still, for a while. Then the energy has crossed over completely and the first one is the one at rest.
EXP-033
Move the cursor over the canvas — it is part of the simulation.
Drag your finger across the canvas — it is part of the simulation.
What to look for
Turn the coupling to zero and the handover stops: two independent oscillators that never talk. Turn it up and they trade energy so fast the two motions blur into one.
The maths
m₁ẍ₁ = −kx₁ − k_c(x₁ − x₂)
Newton for the first mass: its own spring plus the coupling. The second equation is the mirror image.
ω± = √(k/m), √((k+2k_c)/m)
The two normal modes: both masses moving together, and both moving against each other. Any motion at all is a mix of exactly these two.
f_beat = (ω₊ − ω₋)/2π
The beat frequency is the difference between the modes. Weaker coupling means slower handover, which is why the slider changes the rhythm and not the pitch.
How it is built
Two second-order equations integrated with a fixed step, three substeps per frame. The two traces at the bottom are the displacement history of each mass, and the beating is visible there long before you notice it in the masses themselves.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
Each mass feels its own spring plus the shared one. That single coupling term is what makes the system exchange energy instead of being two separate problems.
02
Lift the first mass
if (M.in) { x1 = (M.y / h - 0.5) * 2; v1 = 0; } // el cursor levanta la primera masa
The cursor sets the displacement of the first mass directly and zeroes its velocity. Let go and the coupling starts handing that energy to the second one.
03
Plot both histories
[[h1, 0.85], [h2, 0.4]].forEach(([arr, al]) => {
ctx.strokeStyle = `rgba(${acc},${al})`; ctx.lineWidth = 1.2;
ctx.beginPath();
arr.forEach((v, k) => { const X = w * 0.06 + k * 2, Y = y0 + v * s;
k ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); });
ctx.stroke();
The beating is far easier to read in the traces than in the masses. Two envelopes in antiphase: as one grows the other shrinks, and the total stays constant.
Why it matters
Normal modes are how you solve any system of coupled linear oscillators: find the combinations that do not talk to each other, then everything decouples. Molecules, bridges, circuits and crystal lattices are all this problem with more indices.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · oscillators
{ id: 'oscillators',
name: L('Coupled oscillators', 'Osciladores acoplados'),
note: L('Two masses, one spring between them. Energy moves back and forth on its own.',
'Dos masas, un resorte entre ellas. La energía va y viene sola.'),
params: [
{ key: 'kc', label: L('Coupling', 'Acoplamiento'), min: 0, max: 0.6, step: 0.01, def: 0.18 },
{ key: 'k', label: L('Stiffness', 'Rigidez'), min: 0.2, max: 3, step: 0.05, def: 1 },
{ key: 'mass', label: L('Mass ratio', 'Razón de masas'), min: 0.4, max: 3, step: 0.05, def: 1 }
],
make() {
let x1 = 1, x2 = 0, v1 = 0, v2 = 0, h1 = [], h2 = [];
return {
reset() { x1 = 1; x2 = 0; v1 = v2 = 0; h1 = []; h2 = []; },
step(ctx, w, h, t, acc, P, M) {
if (M.in) { x1 = (M.y / h - 0.5) * 2; v1 = 0; } // el cursor levanta la primera masa
const dt = 0.12;
for (let i = 0; i < 3; i++) {
const a1 = (-P.k * x1 - P.kc * (x1 - x2));
const a2 = (-P.k * x2 - P.kc * (x2 - x1)) / P.mass;
v1 += a1 * dt; v2 += a2 * dt; x1 += v1 * dt; x2 += v2 * dt;
}
h1.push(x1); h2.push(x2);
if (h1.length > w * 0.5) { h1.shift(); h2.shift(); }
const cy = h * 0.32, A = h * 0.16, xa = w * 0.26, xb = w * 0.62;
ctx.strokeStyle = `rgba(${acc},0.3)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(xa, cy + x1 * A); ctx.lineTo(xb, cy + x2 * A); ctx.stroke();
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.beginPath(); ctx.arc(xa, cy + x1 * A, 7, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(xb, cy + x2 * A, 7 * Math.sqrt(P.mass), 0, TAU); ctx.fill();
// Historia de cada masa: el batido se ve solo
const y0 = h * 0.74, s = h * 0.1;
[[h1, 0.85], [h2, 0.4]].forEach(([arr, al]) => {
ctx.strokeStyle = `rgba(${acc},${al})`; ctx.lineWidth = 1.2;
ctx.beginPath();
arr.forEach((v, k) => { const X = w * 0.06 + k * 2, Y = y0 + v * s;
k ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); });
ctx.stroke();
});
}
};
}
},