Here are a few particles scattered in a box. Each pair pushes apart when too close and pulls together when slightly too far — the classic Lennard-Jones force. Press Play and the simulation steps time forward with the velocity-Verlet integrator, the workhorse of real MD codes.
<p class="hint">A handful of particles in a box. Each pair pushes apart when too close and pulls together when slightly too far (the Lennard-Jones force). Press <b>Play</b> to step time forward with velocity-Verlet. Turn on <b>Cooling</b> and they settle into a compact cluster — the lowest-energy structure.</p>
<canvas id="stage" width="320" height="260"></canvas>
<div class="readout" id="readout">Step 0 · energy —</div>
<div class="btns">
<button id="play" type="button">Play</button>
<button id="cool" type="button" class="ghost">Cooling: off</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { background: #0e1726; border-radius: 10px; display: block; width: 100%; max-width: 320px; }
.readout { font: 600 14px ui-monospace, monospace; color: #1d3557; margin: .5rem 0; min-height: 1.3em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.on { background: #0a7d33; border-color: #0a7d33; color: #fff; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Molecular dynamics in 2D with the velocity-Verlet integrator and a Lennard-Jones pair force. The recipe is EXACT in principle: compute forces, half-kick the velocities, drift the positions, recompute forces, half-kick again. With no cooling it conserves energy (it just explores); with cooling it drains kinetic energy and settles into the minimum-energy cluster.
const cv = document.getElementById('stage'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const EPS = 1.0, SIG = 16, RC = 2.8 * SIG; // SIG in pixels
const DT = 0.45; // time step (one frame = one step)
let pos, vel, cooling = false, running = false, steps = 0, raf = null;
function seedSystem() {
// loose grid so no pair starts overlapping (keeps forces — and energy — sane)
pos = []; vel = []; let s = 7;
const rnd = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
let k = 0;
for (let r = 0; r < 3 && k < 7; r++)
for (let c = 0; c < 3 && k < 7; c++) {
pos.push([90 + c * SIG * 1.6 + rnd() * 6, 70 + r * SIG * 1.6 + rnd() * 6]);
vel.push([(rnd() - 0.5) * 2.2, (rnd() - 0.5) * 2.2]);
k++;
}
steps = 0;
}
// forces[i] and total potential energy from the Lennard-Jones pair potential
function compute() {
const n = pos.length, f = pos.map(() => [0, 0]); let pe = 0;
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
let dx = pos[i][0] - pos[j][0], dy = pos[i][1] - pos[j][1];
let r2 = dx * dx + dy * dy;
if (r2 < RC * RC && r2 > 1) {
const inv = 1 / r2, s2 = SIG * SIG * inv, s6 = s2 * s2 * s2, s12 = s6 * s6;
pe += 4 * EPS * (s12 - s6);
const fr = 24 * EPS * (2 * s12 - s6) * inv; // (-dU/dr)/r
f[i][0] += fr * dx; f[i][1] += fr * dy;
f[j][0] -= fr * dx; f[j][1] -= fr * dy;
}
}
return [f, pe];
}
const kinetic = () => vel.reduce((a, v) => a + 0.5 * (v[0] * v[0] + v[1] * v[1]), 0);
let F = null;
function step() {
if (!F) [F] = compute();
const n = pos.length;
for (let i = 0; i < n; i++) { // half kick + drift
vel[i][0] += 0.5 * DT * F[i][0]; vel[i][1] += 0.5 * DT * F[i][1];
pos[i][0] += DT * vel[i][0]; pos[i][1] += DT * vel[i][1];
// reflect off the soft walls so nothing escapes the box
if (pos[i][0] < 10) { pos[i][0] = 10; vel[i][0] *= -1; }
if (pos[i][0] > W - 10) { pos[i][0] = W - 10; vel[i][0] *= -1; }
if (pos[i][1] < 10) { pos[i][1] = 10; vel[i][1] *= -1; }
if (pos[i][1] > H - 10) { pos[i][1] = H - 10; vel[i][1] *= -1; }
}
let pe; [F, pe] = compute();
for (let i = 0; i < n; i++) { // second half kick
vel[i][0] += 0.5 * DT * F[i][0]; vel[i][1] += 0.5 * DT * F[i][1];
if (cooling) { vel[i][0] *= 0.96; vel[i][1] *= 0.96; }
}
steps++;
const E = kinetic() + pe;
document.getElementById('readout').textContent =
fmt('Step %steps% · energy %E%%v%', { steps: steps, E: E.toFixed(1), v: cooling ? ' · cooling' : '' });
}
function draw() {
ctx.clearRect(0, 0, W, H);
// bonds between near neighbors, then atoms
ctx.strokeStyle = 'rgba(120,170,255,.35)'; ctx.lineWidth = 2;
for (let i = 0; i < pos.length; i++) for (let j = i + 1; j < pos.length; j++) {
const dx = pos[i][0] - pos[j][0], dy = pos[i][1] - pos[j][1];
if (dx * dx + dy * dy < (1.5 * SIG) * (1.5 * SIG)) {
ctx.beginPath(); ctx.moveTo(pos[i][0], pos[i][1]); ctx.lineTo(pos[j][0], pos[j][1]); ctx.stroke();
}
}
for (const p of pos) {
const g = ctx.createRadialGradient(p[0] - 3, p[1] - 3, 2, p[0], p[1], SIG / 2);
g.addColorStop(0, '#7fd0ff'); g.addColorStop(1, '#2a6fb0');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(p[0], p[1], SIG / 2, 0, 7); ctx.fill();
}
}
function loop() { if (!running) return; step(); draw(); raf = requestAnimationFrame(loop); }
function reset() { running = false; if (raf) cancelAnimationFrame(raf); F = null;
seedSystem(); draw(); document.getElementById('play').textContent = 'Play';
document.getElementById('readout').textContent = 'Step 0 · energy —'; }
document.getElementById('play').addEventListener('click', e => {
running = !running; e.target.textContent = running ? 'Pause' : 'Play';
if (running) loop();
});
document.getElementById('cool').addEventListener('click', e => {
cooling = !cooling; e.target.textContent = fmt('Cooling: %v%', { v: cooling ? 'on' : 'off' });
e.target.classList.toggle('on', cooling);
});
document.getElementById('reset').addEventListener('click', reset);
seedSystem(); draw();
<p class="hint">A handful of particles in a box. Each pair pushes apart when too close and pulls together when slightly too far (the Lennard-Jones force). Press <b>Play</b> to step time forward with velocity-Verlet. Turn on <b>Cooling</b> and they settle into a compact cluster — the lowest-energy structure.</p>
<canvas id="stage" width="320" height="260"></canvas>
<div class="readout" id="readout">Step 0 · energy —</div>
<div class="btns">
<button id="play" type="button">Play</button>
<button id="cool" type="button" class="ghost">Cooling: off</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
canvas { background: #0e1726; border-radius: 10px; display: block; width: 100%; max-width: 320px; }
.readout { font: 600 14px ui-monospace, monospace; color: #1d3557; margin: .5rem 0; min-height: 1.3em; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.on { background: #0a7d33; border-color: #0a7d33; color: #fff; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Molecular dynamics in 2D with the velocity-Verlet integrator and a Lennard-Jones pair force. The recipe is EXACT in principle: compute forces, half-kick the velocities, drift the positions, recompute forces, half-kick again. With no cooling it conserves energy (it just explores); with cooling it drains kinetic energy and settles into the minimum-energy cluster.
const cv = document.getElementById('stage'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const EPS = 1.0, SIG = 16, RC = 2.8 * SIG; // SIG in pixels
const DT = 0.45; // time step (one frame = one step)
let pos, vel, cooling = false, running = false, steps = 0, raf = null;
function seedSystem() {
// loose grid so no pair starts overlapping (keeps forces — and energy — sane)
pos = []; vel = []; let s = 7;
const rnd = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
let k = 0;
for (let r = 0; r < 3 && k < 7; r++)
for (let c = 0; c < 3 && k < 7; c++) {
pos.push([90 + c * SIG * 1.6 + rnd() * 6, 70 + r * SIG * 1.6 + rnd() * 6]);
vel.push([(rnd() - 0.5) * 2.2, (rnd() - 0.5) * 2.2]);
k++;
}
steps = 0;
}
// forces[i] and total potential energy from the Lennard-Jones pair potential
function compute() {
const n = pos.length, f = pos.map(() => [0, 0]); let pe = 0;
for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
let dx = pos[i][0] - pos[j][0], dy = pos[i][1] - pos[j][1];
let r2 = dx * dx + dy * dy;
if (r2 < RC * RC && r2 > 1) {
const inv = 1 / r2, s2 = SIG * SIG * inv, s6 = s2 * s2 * s2, s12 = s6 * s6;
pe += 4 * EPS * (s12 - s6);
const fr = 24 * EPS * (2 * s12 - s6) * inv; // (-dU/dr)/r
f[i][0] += fr * dx; f[i][1] += fr * dy;
f[j][0] -= fr * dx; f[j][1] -= fr * dy;
}
}
return [f, pe];
}
const kinetic = () => vel.reduce((a, v) => a + 0.5 * (v[0] * v[0] + v[1] * v[1]), 0);
let F = null;
function step() {
if (!F) [F] = compute();
const n = pos.length;
for (let i = 0; i < n; i++) { // half kick + drift
vel[i][0] += 0.5 * DT * F[i][0]; vel[i][1] += 0.5 * DT * F[i][1];
pos[i][0] += DT * vel[i][0]; pos[i][1] += DT * vel[i][1];
// reflect off the soft walls so nothing escapes the box
if (pos[i][0] < 10) { pos[i][0] = 10; vel[i][0] *= -1; }
if (pos[i][0] > W - 10) { pos[i][0] = W - 10; vel[i][0] *= -1; }
if (pos[i][1] < 10) { pos[i][1] = 10; vel[i][1] *= -1; }
if (pos[i][1] > H - 10) { pos[i][1] = H - 10; vel[i][1] *= -1; }
}
let pe; [F, pe] = compute();
for (let i = 0; i < n; i++) { // second half kick
vel[i][0] += 0.5 * DT * F[i][0]; vel[i][1] += 0.5 * DT * F[i][1];
if (cooling) { vel[i][0] *= 0.96; vel[i][1] *= 0.96; }
}
steps++;
const E = kinetic() + pe;
document.getElementById('readout').textContent =
fmt('Step %steps% · energy %E%%v%', { steps: steps, E: E.toFixed(1), v: cooling ? ' · cooling' : '' });
}
function draw() {
ctx.clearRect(0, 0, W, H);
// bonds between near neighbors, then atoms
ctx.strokeStyle = 'rgba(120,170,255,.35)'; ctx.lineWidth = 2;
for (let i = 0; i < pos.length; i++) for (let j = i + 1; j < pos.length; j++) {
const dx = pos[i][0] - pos[j][0], dy = pos[i][1] - pos[j][1];
if (dx * dx + dy * dy < (1.5 * SIG) * (1.5 * SIG)) {
ctx.beginPath(); ctx.moveTo(pos[i][0], pos[i][1]); ctx.lineTo(pos[j][0], pos[j][1]); ctx.stroke();
}
}
for (const p of pos) {
const g = ctx.createRadialGradient(p[0] - 3, p[1] - 3, 2, p[0], p[1], SIG / 2);
g.addColorStop(0, '#7fd0ff'); g.addColorStop(1, '#2a6fb0');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(p[0], p[1], SIG / 2, 0, 7); ctx.fill();
}
}
function loop() { if (!running) return; step(); draw(); raf = requestAnimationFrame(loop); }
function reset() { running = false; if (raf) cancelAnimationFrame(raf); F = null;
seedSystem(); draw(); document.getElementById('play').textContent = 'Play';
document.getElementById('readout').textContent = 'Step 0 · energy —'; }
document.getElementById('play').addEventListener('click', e => {
running = !running; e.target.textContent = running ? 'Pause' : 'Play';
if (running) loop();
});
document.getElementById('cool').addEventListener('click', e => {
cooling = !cooling; e.target.textContent = fmt('Cooling: %v%', { v: cooling ? 'on' : 'off' });
e.target.classList.toggle('on', cooling);
});
document.getElementById('reset').addEventListener('click', reset);
seedSystem(); draw();
Turn on Cooling and the jiggling drains away: the particles slide downhill in energy and settle into a compact, ordered cluster — a tiny crystal — exactly the structure that minimizes their potential energy. Watch the energy readout. Without cooling the total energy barely drifts (the integrator conserves it); the system just explores. Every frame is one step, and you are watching a few dozen of them. A real run needs billions.
Comments
Loading comments...