You build physics demonstrations for a science museum. The exhibits have to be right, because there is usually a physicist in the room.
Build me the falling slinky.
A single HTML page. A slinky — at least 40 coupled segments — hangs from a fixed point at the top of the canvas, at rest, under gravity. Then it is released.
Controls:
data: URIs are fine.localStorage, sessionStorage, cookies or indexedDB — sandboxed frame, they throw.html block.Before the file, and before you write it. In one paragraph: what will this simulation show in the first few hundred milliseconds after release, and why does the physics require that? Be concrete about what each end of the slinky does and roughly when.
Commit to this. It is the claim I am going to test your simulation against.
Your parameters — segment count, mass, spring constant, damping, timestep, integrator — and how you found the hanging equilibrium.
The single fenced html block.
Now be honest. Having written it, does the simulation reproduce what you predicted?
Say plainly if it does not. Say if you are unsure. Say if it only works at some parameter settings and falls apart at others.
Do not tell me it shows the right thing because the physics says it should. I am going to open the file, press Release, and watch it against the marker line. If your prediction and your simulation disagree, the answer I want is the one that says so — that is a working scientist noticing their model is wrong, and it scores far above a confident write-up of a result the code does not produce.
With the nominal settings, and reading the simulated clock rather than the stretched wall time, the top end will start downward immediately on Release; the stretched upper coils will collapse first and a release/compression front will travel down the slinky. The bottom end will stay essentially on the yellow marker line until about 240–270 ms, then start to move below it; by roughly 300–350 ms the whole slinky will be falling and the bottom will be visibly below the marker. This is required because the bottom’s initial spring tension upward is exactly its own weight, so it has zero initial acceleration, while the force that the top support has disappeared can only be communicated by local neighbour springs; meanwhile the centre of mass must accelerate downward at g from the instant of release, so the top/collapse-front motion has to be much faster than free fall to compensate for the still-stationary lower coils.
m = 1.g = 2100 px/s².k = 10000, so k/m = 10000 s⁻².L0 = 10 px.c = 4; no global drag and no velocity/position clamps.dt = 0.00025 s. Integrator: fixed-step classical RK4 on positions and velocities. The highest small-oscillation angular frequency is about 2√(k/m) ≈ 200 s⁻¹, so ωdt ≈ 0.05, well inside RK4’s stable region.topY = 44 px, spring j between masses j and j+1 carries the weight of 43 − j masses below it, so its extension is e_j = (43 − j) m g / k; the separation is L0 + e_j; positions are the cumulative sum. The bottom mass/end-cap height is about 672.7 px. The pre-release top support force is N m g = 44 × 2100.y[N-1], the bottom mass/end-cap height, and is then only drawn; it is not used by the physics. The clock shows simulated milliseconds; the slider runs from 1.00× to 0.05× and stretches wall time.<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Falling Slinky</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #080d14;
color: #e8eef7;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.wrap {
width: min(92vw, 860px);
margin: 18px auto 28px;
}
h1 {
font-size: 1.35rem;
margin: 0 0 12px;
letter-spacing: 0.02em;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 18px;
padding: 10px 12px;
background: #111a27;
border: 1px solid #26354a;
border-radius: 10px;
}
button {
appearance: none;
border: 1px solid #3b536f;
background: #1b2c41;
color: #f5f8fc;
border-radius: 8px;
padding: 8px 14px;
font: 600 14px/1 system-ui, sans-serif;
cursor: pointer;
}
button:hover:not(:disabled) { background: #25405f; }
button:disabled { opacity: 0.45; cursor: not-allowed; }
label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
input[type="range"] { width: 220px; }
.speedval, .clock {
font-variant-numeric: tabular-nums;
color: #bcd0e7;
}
.speedval { min-width: 94px; }
.clock { margin-left: auto; }
output {
color: #ffe08a;
font-weight: 700;
}
canvas {
display: block;
width: 100%;
max-width: 820px;
height: auto;
margin: 12px auto 0;
background: #0b1320;
border: 1px solid #26354a;
border-radius: 10px;
}
.note {
color: #9fb0c4;
font-size: 13px;
line-height: 1.45;
margin: 10px 2px 0;
}
</style>
</head>
<body>
<div class="wrap">
<h1>Falling slinky — marker at the released bottom height</h1>
<div class="controls">
<button id="releaseBtn">Release</button>
<button id="resetBtn">Reset</button>
<label>
Speed
<input id="speed" type="range" min="0.05" max="1" step="0.01" value="0.20">
<span id="speedVal" class="speedval">0.20×</span>
</label>
<span class="clock">simulated clock: <output id="clock">0.0 ms</output></span>
</div>
<canvas id="canvas" width="820" height="760" aria-label="falling slinky simulation"></canvas>
<p class="note">
Reset hangs the solved non-uniform equilibrium and clears the marker. Release places the marker once, through the bottom mass/end-cap height.
The clock is simulated physical time; the speed slider stretches wall time. “bottom Δ” is down-positive from the fixed marker.
</p>
</div>
<script>
(() => {
"use strict";
const W = 820, H = 760;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const releaseBtn = document.getElementById("releaseBtn");
const resetBtn = document.getElementById("resetBtn");
const speedSlider = document.getElementById("speed");
const speedVal = document.getElementById("speedVal");
const clockEl = document.getElementById("clock");
const N = 44;
const mass = 1.0;
const k = 10000.0;
const damping = 4.0;
const g = 2100.0;
const rest = 10.0;
const dt = 0.00025;
const topY = 44.0;
const anchorY = 22.0;
const centerX = W / 2;
const TAU = Math.PI * 2;
let speed = Number(speedSlider.value);
let released = false;
let markerY = null;
let simTime = 0.0;
let accumulator = 0.0;
let lastWall = null;
const pos = new Float64Array(N);
const vel = new Float64Array(N);
const dp1 = new Float64Array(N), dv1 = new Float64Array(N);
const dp2 = new Float64Array(N), dv2 = new Float64Array(N);
const dp3 = new Float64Array(N), dv3 = new Float64Array(N);
const dp4 = new Float64Array(N), dv4 = new Float64Array(N);
const tmpP = new Float64Array(N), tmpV = new Float64Array(N);
function updateClock() {
clockEl.textContent = (simTime * 1000).toFixed(1) + " ms";
}
function updateSpeedLabel() {
speed = Number(speedSlider.value);
let text = speed.toFixed(2) + "×";
if (Math.abs(speed - 1.0) < 1e-12) text += " (full)";
if (Math.abs(speed - 0.05) < 1e-12) text += " (1/20)";
speedVal.textContent = text;
}
function resetEquilibrium() {
pos[0] = topY;
for (let j = 0; j < N - 1; j++) {
const massesBelow = N - 1 - j;
const extension = massesBelow * mass * g / k;
pos[j + 1] = pos[j] + rest + extension;
}
vel.fill(0);
released = false;
markerY = null;
simTime = 0.0;
accumulator = 0.0;
releaseBtn.disabled = false;
updateClock();
}
function releaseTop() {
if (released) return;
markerY = pos[N - 1];
simTime = 0.0;
accumulator = 0.0;
released = true;
releaseBtn.disabled = true;
updateClock();
}
releaseBtn.addEventListener("click", releaseTop);
resetBtn.addEventListener("click", resetEquilibrium);
speedSlider.addEventListener("input", updateSpeedLabel);
function derivative(p, v, dPos, dVel) {
for (let i = 0; i < N; i++) {
dPos[i] = v[i];
dVel[i] = g;
}
for (let j = 0; j < N - 1; j++) {
const d = p[j + 1] - p[j];
const s = d >= 0 ? 1 : -1;
const dist = s * d;
const lengthRate = s * (v[j + 1] - v[j]);
const forceMag = k * (dist - rest) + damping * lengthRate;
const force = s * forceMag;
const accel = force / mass;
dVel[j] += accel;
dVel[j + 1] -= accel;
}
}
function rk4Step(h) {
derivative(pos, vel, dp1, dv1);
for (let i = 0; i < N; i++) {
tmpP[i] = pos[i] + 0.5 * h * dp1[i];
tmpV[i] = vel[i] + 0.5 * h * dv1[i];
}
derivative(tmpP, tmpV, dp2, dv2);
for (let i = 0; i < N; i++) {
tmpP[i] = pos[i] + 0.5 * h * dp2[i];
tmpV[i] = vel[i] + 0.5 * h * dv2[i];
}
derivative(tmpP, tmpV, dp3, dv3);
for (let i = 0; i < N; i++) {
tmpP[i] = pos[i] + h * dp3[i];
tmpV[i] = vel[i] + h * dv3[i];
}
derivative(tmpP, tmpV, dp4, dv4);
const sixth = h / 6;
for (let i = 0; i < N; i++) {
pos[i] += sixth * (dp1[i] + 2 * dp2[i] + 2 * dp3[i] + dp4[i]);
vel[i] += sixth * (dv1[i] + 2 * dv2[i] + 2 * dv3[i] + dv4[i]);
}
simTime += h;
}
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = "#0b1320";
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.strokeStyle = "rgba(255,255,255,0.045)";
ctx.lineWidth = 1;
for (let yy = 50; yy < H; yy += 50) {
ctx.beginPath();
ctx.moveTo(0, yy);
ctx.lineTo(W, yy);
ctx.stroke();
}
for (let xx = 50; xx < W; xx += 50) {
ctx.beginPath();
ctx.moveTo(xx, 0);
ctx.lineTo(xx, H);
ctx.stroke();
}
ctx.restore();
ctx.save();
ctx.strokeStyle = "#8fa3bd";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(centerX - 38, anchorY - 8);
ctx.lineTo(centerX + 38, anchorY - 8);
ctx.stroke();
ctx.fillStyle = "#8fa3bd";
ctx.font = "12px system-ui, sans-serif";
ctx.fillText("fixed point", centerX + 48, anchorY - 4);
if (!released) {
ctx.beginPath();
ctx.moveTo(centerX, anchorY);
ctx.lineTo(centerX, pos[0]);
ctx.stroke();
} else {
ctx.strokeStyle = "rgba(255,120,120,0.85)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(centerX - 8, anchorY - 8);
ctx.lineTo(centerX + 8, anchorY + 8);
ctx.moveTo(centerX + 8, anchorY - 8);
ctx.lineTo(centerX - 8, anchorY + 8);
ctx.stroke();
}
ctx.restore();
ctx.save();
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
let first = true;
const amp = 27;
for (let j = 0; j < N - 1; j++) {
const y0 = pos[j];
const y1 = pos[j + 1];
const height = y1 - y0;
const samples = Math.max(5, Math.min(24, Math.round(Math.abs(height) / 1.6) + 5));
for (let n = 0; n <= samples; n++) {
const t = n / samples;
const yy = y0 + height * t;
const phase = TAU * t + j * Math.PI;
const xx = centerX + amp * Math.sin(phase);
if (first) {
ctx.moveTo(xx, yy);
first = false;
} else {
ctx.lineTo(xx, yy);
}
}
}
ctx.strokeStyle = "rgba(3,8,15,0.75)";
ctx.lineWidth = 8;
ctx.stroke();
ctx.strokeStyle = "rgba(55,165,235,0.35)";
ctx.lineWidth = 6;
ctx.stroke();
ctx.strokeStyle = "#a7e5ff";
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.restore();
ctx.save();
ctx.fillStyle = "#ffe08a";
for (let i = 0; i < N; i++) {
if (pos[i] > -8 && pos[i] < H + 8) {
ctx.beginPath();
ctx.arc(centerX, pos[i], 2.2, 0, TAU);
ctx.fill();
}
}
ctx.restore();
if (markerY !== null) {
ctx.save();
ctx.setLineDash([10, 7]);
ctx.globalAlpha = 0.92;
ctx.strokeStyle = "#ffd166";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, markerY);
ctx.lineTo(W, markerY);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
ctx.fillStyle = "#ffd166";
ctx.font = "13px system-ui, sans-serif";
ctx.fillText("marker — bottom at release", 14, markerY - 9);
ctx.restore();
}
ctx.save();
ctx.lineCap = "round";
ctx.strokeStyle = "#ffd166";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(centerX - 30, pos[N - 1]);
ctx.lineTo(centerX + 30, pos[N - 1]);
ctx.moveTo(centerX - 30, pos[0]);
ctx.lineTo(centerX + 30, pos[0]);
ctx.stroke();
ctx.restore();
ctx.save();
ctx.font = "13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
ctx.fillStyle = "#c6d3e3";
ctx.fillText(
released
? "RELEASED: gravity + identical neighbour springs; support removed"
: "HANGING: analytic equilibrium; top support holds Nmg",
14, H - 48
);
ctx.font = "12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
ctx.fillStyle = "#94a7bd";
ctx.fillText(
"N=" + N + " masses, " + (N - 1) + " springs | k/m=" +
(k / mass).toFixed(0) + " s^-2 | dt=" + (dt * 1000).toFixed(2) + " ms",
14, H - 29
);
if (markerY !== null) {
const delta = pos[N - 1] - markerY;
ctx.fillStyle = "#ffe08a";
ctx.fillText(
"bottom Δ from marker: " + (delta >= 0 ? "+" : "") + delta.toFixed(1) + " px (down +)",
14, H - 10
);
} else {
ctx.fillStyle = "#94a7bd";
ctx.fillText("Release will place the fixed marker at this hanging bottom.", 14, H - 10);
}
ctx.restore();
}
function frame(now) {
if (lastWall === null) lastWall = now;
const wallDt = (now - lastWall) / 1000;
lastWall = now;
if (released) {
accumulator += wallDt * speed;
while (accumulator >= dt) {
rk4Step(dt);
accumulator -= dt;
}
updateClock();
}
draw();
requestAnimationFrame(frame);
}
resetEquilibrium();
updateSpeedLabel();
requestAnimationFrame(frame);
})();
</script>
</body>
</html>
Honestly: I am not able to run this page in your browser here, so I am unsure of the exact rendered timing and amplitude. I wrote the file to reproduce the prediction at the nominal settings, and the passive bottom Δ readout is there specifically so you can check it: if the bottom moves by many pixels before roughly 200 ms, or if it has not begun going positive by roughly 300–350 ms, then the prediction above is wrong and I would trust the readout over this write-up.
It does not reproduce a fully realistic metal slinky after the first collapse: this is a linear Hooke/dashpot chain, with no coil-contact, inelastic impact, or hard non-crossing constraint. After the release front reaches the bottom, the collapsed chain can oscillate and, if run long enough or with lower damping, neighbouring masses can pass through one another. The early marker-line behaviour is the part I expect to match. It also only works in the stated stiffness/timestep range: substantially raising k without shortening dt can destabilise RK4, very small damping leaves more oscillation, and large damping smears the front so the bottom motion starts less sharply. No velocity clamp or scripted delay is used to hide any of that.