A sheet of arrows that only care about their four neighbours. Warm it and they argue. Cool it and at one precise temperature the entire sheet picks a side at once, with nothing telling it to.
EXP-020
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
Move the cursor left and right over the canvas: that sweeps the temperature directly, overriding the slider. Cross 2.269 and watch the trace at the bottom fall off a cliff. Near the critical point look at the grid itself, where you get domains of every size at once, from a few pixels to the whole frame. That scale-free look is the signature of criticality.
The maths
E = −Σ⟨ij⟩ sᵢsⱼ − B Σᵢ sᵢ
The energy of the whole sheet. Neighbours that agree cost less; the second term is an external field that bribes everyone toward one side. There is no long-range term anywhere: every interaction is between touching neighbours.
ΔE = 2sᵢ(Σ vecinos + B)
Flipping one spin changes the energy by exactly this. It only needs the four neighbours, which is why the simulation is cheap: the cost of a move is local even though the effect is not.
P(aceptar) = min(1, e^−ΔE/T)
The Metropolis rule. Downhill moves always happen; uphill moves happen with a probability that collapses as the temperature drops. The whole phase transition comes out of this single exponential.
T_c = 2 / ln(1+√2) = 2.269…
Onsager solved the two-dimensional model exactly in 1944 and this number fell out. It is not fitted or measured here: it is a closed form, and the simulation lands on it. Run the sweep and the magnetisation holds near 1 below it and collapses above.
How it is built
Metropolis Monte Carlo on a grid with periodic edges. Each frame proposes thousands of random flips and accepts them by the exponential above. One detail matters more than it looks: the grid starts fully ordered, not random. Starting from noise at low temperature freezes domains that would take astronomically long to merge, and the magnetisation readout would sit near zero and lie to you about the transition. I measured both: ordered start gives 1.000 / 0.960 / 0.874 / 0.657 at T = 1.0 / 1.8 / 2.1 / 2.269, which is the textbook curve. A quench from noise gets stuck around 0.1 at every temperature below T_c.
The code, step by step
Cut straight from lab.js. These are the real lines that run above, not a simplified version.
01
The grid, and why it starts ordered
const TC = 2 / Math.log(1 + Math.SQRT2); // 2.269..., exacta (Onsager)
let N = 0, sp = null, hist = [];
// Arranca ORDENADO a propósito. Desde el azar, a baja temperatura el
// sistema congela dominios que tardan enormidades en fusionarse y |M|
// se queda cerca de cero: el número mentiría sobre la transición.
const init = n => { N = n; sp = new Int8Array(N * N).fill(1); hist = []; };
Int8Array because a spin is +1 or −1 and nothing else. The fill(1) is the part I got wrong first: starting from random noise looks more natural but at low temperature it freezes into domains that never merge, and the magnetisation reads near zero when it should read near one. Measured both before choosing.
02
Metropolis, the whole physics
const flips = (P.sw | 0) * N * N / 6;
for (let f = 0; f < flips; f++) {
const i = (Math.random() * N) | 0, j = (Math.random() * N) | 0, k = j * N + i;
const s = sp[k];
const nb = sp[j * N + ((i + 1) % N)] + sp[j * N + ((i - 1 + N) % N)]
+ sp[((j + 1) % N) * N + i] + sp[((j - 1 + N) % N) * N + i];
const dE = 2 * s * (nb + P.B);
if (dE <= 0 || Math.random() < Math.exp(-dE / T)) sp[k] = -s;
Pick a spin at random, add up its four neighbours with wraparound, and the energy change of flipping it is 2s(nb+B). Downhill always accepted; uphill accepted with e^(−ΔE/T). That single exponential is where the phase transition comes from — there is no other physics in this file.
03
Magnetisation
let m = 0;
for (let i = 0; i < sp.length; i++) m += sp[i];
m /= sp.length;
hist.push(Math.abs(m));
if (hist.length > 240) hist.shift();
The order parameter: the average spin. Near 1 means the sheet agrees, near 0 means it does not. Keeping the absolute value matters because which side wins is arbitrary, and the history buffer is what draws the trace that falls off a cliff at T_c.
Why it matters
This is the simplest thing that has a phase transition, and it was the first one anybody solved exactly. Its importance is universality: near the critical point, a magnet, a liquid boiling and a binary alloy separating all follow the same exponents, and the microscopic details wash out. It is the reason physicists believe that some collective behaviour does not care what it is made of.
The whole thing
Everything above, in one piece. This is the entire experiment as it lives in lab.js.
FULL
lab.js · ising
{ id: 'ising',
name: L('Ising model', 'Modelo de Ising'),
note: L('Spins that copy their neighbours. Below a critical temperature they all agree.',
'Espines que copian a sus vecinos. Bajo cierta temperatura todos se ponen de acuerdo.'),
params: [
{ key: 'T', label: L('Temperature T', 'Temperatura T'), min: 0.4, max: 5, step: 0.02, def: 2.6 },
{ key: 'B', label: L('External field', 'Campo externo'), min: -0.5, max: 0.5, step: 0.01, def: 0 },
{ key: 'sw', label: L('Sweeps per frame', 'Barridos por frame'), min: 1, max: 40, step: 1, def: 12 }
],
make() {
const TC = 2 / Math.log(1 + Math.SQRT2); // 2.269..., exacta (Onsager)
let N = 0, sp = null, hist = [];
// Arranca ORDENADO a propósito. Desde el azar, a baja temperatura el
// sistema congela dominios que tardan enormidades en fusionarse y |M|
// se queda cerca de cero: el número mentiría sobre la transición.
const init = n => { N = n; sp = new Int8Array(N * N).fill(1); hist = []; };
return {
reset() { init(N || 110); },
step(ctx, w, h, t, acc, P, M) {
const n = Math.min(140, Math.max(40, Math.floor(Math.min(w, h) / 4)));
if (n !== N) init(n);
const T = M.in ? 0.4 + (M.x / w) * 4.6 : P.T;
// Metropolis: propone dar vuelta un espín y acepta con exp(−ΔE/T).
// Toda la transición de fase sale de esa única exponencial.
const flips = (P.sw | 0) * N * N / 6;
for (let f = 0; f < flips; f++) {
const i = (Math.random() * N) | 0, j = (Math.random() * N) | 0, k = j * N + i;
const s = sp[k];
const nb = sp[j * N + ((i + 1) % N)] + sp[j * N + ((i - 1 + N) % N)]
+ sp[((j + 1) % N) * N + i] + sp[((j - 1 + N) % N) * N + i];
const dE = 2 * s * (nb + P.B);
if (dE <= 0 || Math.random() < Math.exp(-dE / T)) sp[k] = -s;
}
let m = 0;
for (let i = 0; i < sp.length; i++) m += sp[i];
m /= sp.length;
hist.push(Math.abs(m));
if (hist.length > 240) hist.shift();
// Dibujo de la rejilla
const S = Math.min(w, h) * 0.74, ox = (w - S) / 2, oy = h * 0.06, cs = S / N;
const img = ctx.createImageData(N, N), px = img.data;
const rgb = acc.split(',').map(Number);
for (let k = 0; k < sp.length; k++) {
const o = k * 4, up = sp[k] > 0;
px[o] = up ? rgb[0] : 12; px[o+1] = up ? rgb[1] : 12; px[o+2] = up ? rgb[2] : 14; px[o+3] = 255;
}
const off = document.createElement('canvas');
off.width = off.height = N; off.getContext('2d').putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = false;
ctx.drawImage(off, ox, oy, S, S);
ctx.strokeStyle = `rgba(${acc},0.4)`; ctx.lineWidth = 1;
ctx.strokeRect(ox, oy, S, S);
// Magnetización en el tiempo y dónde estás respecto a Tc
const gx = ox, gw = S, gy = h * 0.95, gh = h * 0.10;
ctx.strokeStyle = `rgba(${acc},0.18)`;
ctx.beginPath(); ctx.moveTo(gx, gy); ctx.lineTo(gx + gw, gy); ctx.stroke();
ctx.strokeStyle = `rgba(${acc},0.9)`; ctx.lineWidth = 1.4;
ctx.beginPath();
hist.forEach((v, i) => { const X = gx + (i / 240) * gw, Y = gy - v * gh;
i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); });
ctx.stroke();
ctx.font = '11px monospace';
ctx.fillStyle = `rgba(${acc},0.55)`;
ctx.fillText('T_c = 2/ln(1+√2) = ' + TC.toFixed(3) + ' (exact, Onsager 1944)', 12, h - 28);
ctx.fillStyle = `rgba(${acc},0.95)`;
ctx.fillText('T = ' + T.toFixed(2) + ' |M| = ' + Math.abs(m).toFixed(3) + ' ' +
(T < TC ? 'ordered' : 'disordered'), 12, h - 12);
}
};
}
},