A gas let go in one corner spreads out and never gathers back. Nothing in the equations forbids it gathering. What forbids it is counting.
EXP-019
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
Flip the reverse switch once the gas has spread. Every velocity is negated and the whole thing retraces its path back into the corner, entropy falling the entire way. I measured it: reversing at step 600 sends S from 0.964 back to 0.313, the exact value it started from. Then set the particle count to four and watch entropy wander up and down on its own. At two hundred and forty it stops wandering, and that is the only reason the second law looks like a law.
The maths
S = k·ln Ω
Boltzmann. Entropy is the logarithm of how many microscopic arrangements look the same from outside. It is a property of your description, not of the particles.
S = −Σ pᵢ ln pᵢ
The form used here, over the occupancy of each cell. Normalised by the log of the cell count so the maximum is one, which is the state where every cell holds the same share.
Ω_spread / Ω_corner ≈ 2^N
Why it never goes back. With a few hundred particles that ratio already exceeds the number of atoms in the observable universe. Nothing is forbidden; it is just outnumbered.
How it is built
Particles move in straight lines and bounce off the walls, with no collisions between them, which makes this a free expansion of an ideal gas and makes the dynamics exactly reversible. The shading is the coarse-grained description: how many particles are in each cell, which is all a macroscopic observer gets to know. Entropy is computed from those occupancies every frame and plotted underneath.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
01
Reversing time is one minus sign
if (P0.rev !== lastRev) { lastRev = P0.rev; for (const p of P) { p.vx = -p.vx; p.vy = -p.vy; } }
That is the whole of Loschmidt objection, in code. Negating every velocity is a perfectly legal state of the same physics, and it runs the film backwards with entropy falling. No law is broken, which is exactly the uncomfortable part.
02
Entropy of the description
const cnt = new Float64Array(C * C);
for (const p of P) {
const cx = Math.min(C - 1, (p.x / w * C) | 0);
const cy = Math.min(C - 1, (p.y / boxH * C) | 0);
cnt[cy * C + cx]++;
}
let S = 0;
for (let i = 0; i < cnt.length; i++) {
if (!cnt[i]) continue;
const q = cnt[i] / P.length;
S -= q * Math.log(q);
}
S /= Math.log(C * C);
Only cell occupancies enter the sum. The exact positions are never used, and that is deliberate: entropy measures how much you are not tracking. Coarsen the grid and it drops, refine it and it rises, on the very same particles.
03
Lowering it costs work
if (M.in && M.y < boxH) {
const dx = p.x - M.x, dy = p.y - M.y, d = Math.hypot(dx, dy) + 1;
if (d < 90) { const g = (1 - d / 90) * 0.5; p.vx += (dx / d) * g; p.vy += (dy / d) * g; }
The cursor pushes particles away, so you can herd them into a corner and drive the entropy down by hand. It takes continuous effort and it undoes itself the moment you stop, which is the honest version of what a refrigerator does.
Why it matters
Loschmidt raised the reversal objection to Boltzmann in 1876 and it is not answerable by finding an error in the mechanics, because there is none. The answer is that entropy increase is overwhelmingly probable rather than certain, and that the initial state of the universe was one of extraordinarily low entropy. Everything we call the direction of time rests on that second fact, which physics describes and does not explain.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · entropy
{ id: 'entropy',
name: L('Entropy', 'Entropía'),
note: L('Gas released in a corner. Reverse every velocity and watch it go home.',
'Un gas soltado en una esquina. Invierte todas las velocidades y míralo volver.'),
params: [
{ key: 'n', label: L('Particles', 'Partículas'), min: 4, max: 600, step: 1, def: 240 },
{ key: 'cells', label: L('Coarse-graining', 'Grano de la descripción'), min: 2, max: 16, step: 1, def: 8 },
{ key: 'rev', label: L('Reverse time', 'Invertir el tiempo'), min: 0, max: 1, step: 1, def: 0 }
],
make(w, h) {
// Partículas sin colisiones entre sí: es una expansión libre de gas
// ideal. Sin colisiones la dinámica es exactamente reversible, que es
// justo lo que hace falta para el argumento de Loschmidt.
let P = [], hist = [], lastRev = 0, lastN = 0, boxH = 0;
const seed = (n, w, h) => {
P = [];
for (let i = 0; i < n; i++) {
const a = Math.random() * TAU, sp = 0.8 + Math.random() * 1.6;
P.push({ x: w * 0.06 + Math.random() * w * 0.16,
y: h * 0.10 + Math.random() * h * 0.16,
vx: Math.cos(a) * sp, vy: Math.sin(a) * sp });
}
hist = [];
};
return {
reset() { lastN = 0; },
step(ctx, w, h, t, acc, P0, M) {
boxH = h * 0.66;
const n = P0.n | 0, C = P0.cells | 0;
if (n !== lastN) { seed(n, w, boxH); lastN = n; }
// Cambiar el control invierte todas las velocidades a la vez
if (P0.rev !== lastRev) { lastRev = P0.rev; for (const p of P) { p.vx = -p.vx; p.vy = -p.vy; } }
for (const p of P) {
// El cursor empuja: bajar la entropía localmente cuesta trabajo
if (M.in && M.y < boxH) {
const dx = p.x - M.x, dy = p.y - M.y, d = Math.hypot(dx, dy) + 1;
if (d < 90) { const g = (1 - d / 90) * 0.5; p.vx += (dx / d) * g; p.vy += (dy / d) * g; }
}
p.x += p.vx; p.y += p.vy;
if (p.x < 2) { p.x = 2; p.vx = -p.vx; }
if (p.x > w - 2) { p.x = w - 2; p.vx = -p.vx; }
if (p.y < 2) { p.y = 2; p.vy = -p.vy; }
if (p.y > boxH - 2) { p.y = boxH - 2; p.vy = -p.vy; }
}
// Entropía de grano grueso: contar ocupación por celda.
// S = −Σ pᵢ·ln pᵢ, normalizada por ln(C²) para que el máximo sea 1.
const cnt = new Float64Array(C * C);
for (const p of P) {
const cx = Math.min(C - 1, (p.x / w * C) | 0);
const cy = Math.min(C - 1, (p.y / boxH * C) | 0);
cnt[cy * C + cx]++;
}
let S = 0;
for (let i = 0; i < cnt.length; i++) {
if (!cnt[i]) continue;
const q = cnt[i] / P.length;
S -= q * Math.log(q);
}
S /= Math.log(C * C);
hist.push(S);
if (hist.length > w * 0.86) hist.shift();
// Celdas: el sombreado ES la descripción macroscópica
const cw = w / C, ch = boxH / C;
for (let i = 0; i < C; i++) {
for (let jj = 0; jj < C; jj++) {
const q = cnt[jj * C + i] / Math.max(1, P.length);
if (q <= 0) continue;
ctx.fillStyle = `rgba(${acc},${Math.min(0.30, q * 3.2).toFixed(3)})`;
ctx.fillRect(i * cw, jj * ch, cw, ch);
}
}
ctx.strokeStyle = `rgba(${acc},0.10)`; ctx.lineWidth = 1;
for (let i = 1; i < C; i++) {
ctx.beginPath(); ctx.moveTo(i * cw, 0); ctx.lineTo(i * cw, boxH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0, i * ch); ctx.lineTo(w, i * ch); ctx.stroke();
}
ctx.strokeStyle = `rgba(${acc},0.5)`; ctx.lineWidth = 1.2;
ctx.strokeRect(1, 1, w - 2, boxH - 2);
ctx.fillStyle = `rgba(${acc},0.95)`;
for (const p of P) { ctx.beginPath(); ctx.arc(p.x, p.y, 1.5, 0, TAU); ctx.fill(); }
// Curva de S en el tiempo
const gy = h * 0.90, gh = h * 0.17, gx = w * 0.07;
ctx.strokeStyle = `rgba(${acc},0.2)`; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(gx, gy - gh); ctx.lineTo(gx + w * 0.86, gy - gh); ctx.stroke();
ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx + w * 0.86, gy); ctx.stroke();
ctx.strokeStyle = `rgba(${acc},0.9)`; ctx.lineWidth = 1.5;
ctx.beginPath();
hist.forEach((v, i) => { const X = gx + i, Y = gy - v * gh;
i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); });
ctx.stroke();
ctx.font = '10px monospace'; ctx.fillStyle = `rgba(${acc},0.45)`;
ctx.fillText('S max', gx - 4, gy - gh - 4);
ctx.font = '11px monospace';
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('S = −Σ pᵢ ln pᵢ the equations do not know which way is forward', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.fillText('S / S_max = ' + S.toFixed(3) + ' N = ' + P.length +
' cells ' + C + '×' + C, 12, h - 12);
}
};
}
},