<p class="hint">Each cell holds grains. When a cell reaches <b>4+</b> grains it topples,
sending 1 grain to each neighbor. Grains that fall off the edge are lost.
Watch avalanches of all sizes emerge — no tuning required.</p>
<div class="layout">
<div>
<canvas id="grid" width="220" height="220"></canvas>
<div class="btns">
<button id="drop" type="button">Drop grain</button>
<button id="auto" type="button">Auto-drop</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status">Ready. Drop a grain to start.</div>
</div>
<div class="right-panel">
<div class="panel-label">Avalanche sizes (last 200 drops)</div>
<canvas id="chart" width="220" height="140"></canvas>
<div class="log-label">Recent avalanches</div>
<div id="log" class="log"></div>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.layout { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
canvas#grid { display: block; border: 1px solid #cdd9e3; border-radius: 6px; cursor: pointer; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; margin: .5rem 0 .35rem; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #457b9d; border-color: #457b9d; }
.status { font-size: .85rem; font-weight: 600; color: #1d3557; min-height: 1.3em; }
.right-panel { display: flex; flex-direction: column; gap: .3rem; }
.panel-label, .log-label { font-size: .78rem; font-weight: 600; color: #555; }
canvas#chart { display: block; border: 1px solid #cdd9e3; border-radius: 6px; background: #f6f9fc; }
.log { font-size: .78rem; color: #444; height: 90px; overflow-y: auto;
border: 1px solid #e0e7ed; border-radius: 5px; padding: .3rem .4rem;
background: #fafcfe; font-variant-numeric: tabular-nums; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// BTW (Bak-Tang-Wiesenfeld) sandpile model.
// Rule: if cell >= THRESHOLD, it topples: loses 4 grains, each neighbor gains 1.
// Grains leaving the grid boundary are lost (open boundary conditions).
const N = 11; // grid dimension (11x11)
const THRESHOLD = 4;
const CELL = 20; // pixels per cell
const PALETTE = ['#f0f4f8','#a8d0e6','#4a90d9','#1d3557','#e63946']; // 0,1,2,3,>=4
const gridCanvas = document.getElementById('grid');
const chartCanvas = document.getElementById('chart');
const gCtx = gridCanvas.getContext('2d');
const cCtx = chartCanvas.getContext('2d');
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
let grid = Array.from({length:N}, () => new Array(N).fill(0));
let avalancheSizes = []; // history of avalanche sizes
let autoRunning = false;
let autoTimer = null;
let totalDrops = 0;
function neighbors(r, c) {
const result = [];
if (r > 0) result.push([r-1, c]);
if (r < N-1) result.push([r+1, c]);
if (c > 0) result.push([r, c-1]);
if (c < N-1) result.push([r, c+1]);
return result;
}
// Topple all unstable cells until stable. Returns number of cells toppled.
function topple() {
let totalTopples = 0;
let changed = true;
while (changed) {
changed = false;
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
if (grid[r][c] >= THRESHOLD) {
grid[r][c] -= THRESHOLD;
neighbors(r, c).forEach(([nr, nc]) => { grid[nr][nc] += 1; });
totalTopples++;
changed = true;
}
}
}
}
return totalTopples;
}
function dropGrain(r, c) {
grid[r][c] += 1;
const size = topple();
totalDrops++;
avalancheSizes.push(size);
if (avalancheSizes.length > 200) avalancheSizes.shift();
const msg = size === 0 ? 'No avalanche (grain absorbed)' :
size === 1 ? 'Tiny avalanche: 1 cell toppled' :
fmt('Avalanche: %size% cells toppled', { size: size });
logEl.insertAdjacentHTML('afterbegin', '<div>' + msg + '</div>');
if (logEl.children.length > 40) logEl.removeChild(logEl.lastChild);
statusEl.textContent = fmt('Drop #%totalDrops% — %msg%', { totalDrops: totalDrops, msg: msg });
drawGrid();
drawChart();
}
function drawGrid() {
gCtx.clearRect(0, 0, gridCanvas.width, gridCanvas.height);
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
const v = Math.min(grid[r][c], 4);
gCtx.fillStyle = PALETTE[v];
gCtx.fillRect(c * CELL, r * CELL, CELL - 1, CELL - 1);
if (grid[r][c] > 0) {
gCtx.fillStyle = v >= 3 ? '#fff' : '#1d3557';
gCtx.font = 'bold 11px monospace';
gCtx.textAlign = 'center';
gCtx.textBaseline = 'middle';
gCtx.fillText(grid[r][c], c * CELL + CELL/2 - 0.5, r * CELL + CELL/2);
}
}
}
}
// Log-log bar chart of avalanche size distribution.
function drawChart() {
const W = chartCanvas.width, H = chartCanvas.height;
cCtx.clearRect(0, 0, W, H);
cCtx.fillStyle = '#f6f9fc';
cCtx.fillRect(0, 0, W, H);
// Bin counts: bin i covers sizes [2^i, 2^(i+1))
const maxBins = 8;
const bins = new Array(maxBins).fill(0);
avalancheSizes.forEach(s => {
if (s === 0) return;
const b = Math.min(Math.floor(Math.log2(s)), maxBins - 1);
bins[b]++;
});
const maxCount = Math.max(...bins, 1);
const pad = {l:28, r:8, t:8, b:22};
const cW = W - pad.l - pad.r;
const cH = H - pad.t - pad.b;
const bw = cW / maxBins;
// Axes
cCtx.strokeStyle = '#aaa'; cCtx.lineWidth = 1;
cCtx.beginPath();
cCtx.moveTo(pad.l, pad.t); cCtx.lineTo(pad.l, pad.t + cH);
cCtx.lineTo(pad.l + cW, pad.t + cH); cCtx.stroke();
// Bars
bins.forEach((count, i) => {
if (count === 0) return;
const logH = Math.log2(count + 1) / Math.log2(maxCount + 1);
const bH = logH * cH;
const x = pad.l + i * bw + 1;
const y = pad.t + cH - bH;
cCtx.fillStyle = '#4a90d9';
cCtx.fillRect(x, y, bw - 2, bH);
});
// X-axis labels (log2 bin edges)
cCtx.fillStyle = '#555'; cCtx.font = '9px system-ui'; cCtx.textAlign = 'center';
for (let i = 0; i < maxBins; i++) {
const label = i === 0 ? '1' : Math.pow(2,i).toString();
cCtx.fillText(label, pad.l + i * bw + bw/2, pad.t + cH + 14);
}
// Y label
cCtx.save(); cCtx.translate(10, pad.t + cH/2);
cCtx.rotate(-Math.PI/2); cCtx.textAlign = 'center';
cCtx.fillText('count', 0, 0); cCtx.restore();
// Title
cCtx.textAlign = 'center'; cCtx.font = '9px system-ui'; cCtx.fillStyle = '#777';
cCtx.fillText('size (log₂ bins)', pad.l + cW/2, H - 4);
}
// Click on grid to drop grain there
gridCanvas.addEventListener('click', (e) => {
const rect = gridCanvas.getBoundingClientRect();
const scaleX = gridCanvas.width / rect.width;
const scaleY = gridCanvas.height / rect.height;
const c = Math.floor((e.clientX - rect.left) * scaleX / CELL);
const r = Math.floor((e.clientY - rect.top) * scaleY / CELL);
if (r >= 0 && r < N && c >= 0 && c < N) dropGrain(r, c);
});
document.getElementById('drop').addEventListener('click', () => {
dropGrain(Math.floor(N/2), Math.floor(N/2));
});
const autoBtn = document.getElementById('auto');
autoBtn.addEventListener('click', () => {
autoRunning = !autoRunning;
autoBtn.textContent = autoRunning ? 'Stop auto' : 'Auto-drop';
autoBtn.classList.toggle('active', autoRunning);
if (autoRunning) {
function step() {
if (!autoRunning) return;
dropGrain(Math.floor(N/2), Math.floor(N/2));
autoTimer = setTimeout(step, 120);
}
step();
} else {
clearTimeout(autoTimer);
}
});
document.getElementById('reset').addEventListener('click', () => {
autoRunning = false; autoBtn.textContent = 'Auto-drop'; autoBtn.classList.remove('active');
clearTimeout(autoTimer);
grid = Array.from({length:N}, () => new Array(N).fill(0));
avalancheSizes = []; totalDrops = 0;
logEl.innerHTML = ''; statusEl.textContent = 'Ready. Drop a grain to start.';
drawGrid(); drawChart();
});
drawGrid();
drawChart();
<p class="hint">Each cell holds grains. When a cell reaches <b>4+</b> grains it topples,
sending 1 grain to each neighbor. Grains that fall off the edge are lost.
Watch avalanches of all sizes emerge — no tuning required.</p>
<div class="layout">
<div>
<canvas id="grid" width="220" height="220"></canvas>
<div class="btns">
<button id="drop" type="button">Drop grain</button>
<button id="auto" type="button">Auto-drop</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
<div class="status" id="status">Ready. Drop a grain to start.</div>
</div>
<div class="right-panel">
<div class="panel-label">Avalanche sizes (last 200 drops)</div>
<canvas id="chart" width="220" height="140"></canvas>
<div class="log-label">Recent avalanches</div>
<div id="log" class="log"></div>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; background: #fff; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.layout { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-start; }
canvas#grid { display: block; border: 1px solid #cdd9e3; border-radius: 6px; cursor: pointer; }
.btns { display: flex; gap: .4rem; flex-wrap: wrap; margin: .5rem 0 .35rem; }
button { font: 600 13px system-ui; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #457b9d; border-color: #457b9d; }
.status { font-size: .85rem; font-weight: 600; color: #1d3557; min-height: 1.3em; }
.right-panel { display: flex; flex-direction: column; gap: .3rem; }
.panel-label, .log-label { font-size: .78rem; font-weight: 600; color: #555; }
canvas#chart { display: block; border: 1px solid #cdd9e3; border-radius: 6px; background: #f6f9fc; }
.log { font-size: .78rem; color: #444; height: 90px; overflow-y: auto;
border: 1px solid #e0e7ed; border-radius: 5px; padding: .3rem .4rem;
background: #fafcfe; font-variant-numeric: tabular-nums; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// BTW (Bak-Tang-Wiesenfeld) sandpile model.
// Rule: if cell >= THRESHOLD, it topples: loses 4 grains, each neighbor gains 1.
// Grains leaving the grid boundary are lost (open boundary conditions).
const N = 11; // grid dimension (11x11)
const THRESHOLD = 4;
const CELL = 20; // pixels per cell
const PALETTE = ['#f0f4f8','#a8d0e6','#4a90d9','#1d3557','#e63946']; // 0,1,2,3,>=4
const gridCanvas = document.getElementById('grid');
const chartCanvas = document.getElementById('chart');
const gCtx = gridCanvas.getContext('2d');
const cCtx = chartCanvas.getContext('2d');
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
let grid = Array.from({length:N}, () => new Array(N).fill(0));
let avalancheSizes = []; // history of avalanche sizes
let autoRunning = false;
let autoTimer = null;
let totalDrops = 0;
function neighbors(r, c) {
const result = [];
if (r > 0) result.push([r-1, c]);
if (r < N-1) result.push([r+1, c]);
if (c > 0) result.push([r, c-1]);
if (c < N-1) result.push([r, c+1]);
return result;
}
// Topple all unstable cells until stable. Returns number of cells toppled.
function topple() {
let totalTopples = 0;
let changed = true;
while (changed) {
changed = false;
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
if (grid[r][c] >= THRESHOLD) {
grid[r][c] -= THRESHOLD;
neighbors(r, c).forEach(([nr, nc]) => { grid[nr][nc] += 1; });
totalTopples++;
changed = true;
}
}
}
}
return totalTopples;
}
function dropGrain(r, c) {
grid[r][c] += 1;
const size = topple();
totalDrops++;
avalancheSizes.push(size);
if (avalancheSizes.length > 200) avalancheSizes.shift();
const msg = size === 0 ? 'No avalanche (grain absorbed)' :
size === 1 ? 'Tiny avalanche: 1 cell toppled' :
fmt('Avalanche: %size% cells toppled', { size: size });
logEl.insertAdjacentHTML('afterbegin', '<div>' + msg + '</div>');
if (logEl.children.length > 40) logEl.removeChild(logEl.lastChild);
statusEl.textContent = fmt('Drop #%totalDrops% — %msg%', { totalDrops: totalDrops, msg: msg });
drawGrid();
drawChart();
}
function drawGrid() {
gCtx.clearRect(0, 0, gridCanvas.width, gridCanvas.height);
for (let r = 0; r < N; r++) {
for (let c = 0; c < N; c++) {
const v = Math.min(grid[r][c], 4);
gCtx.fillStyle = PALETTE[v];
gCtx.fillRect(c * CELL, r * CELL, CELL - 1, CELL - 1);
if (grid[r][c] > 0) {
gCtx.fillStyle = v >= 3 ? '#fff' : '#1d3557';
gCtx.font = 'bold 11px monospace';
gCtx.textAlign = 'center';
gCtx.textBaseline = 'middle';
gCtx.fillText(grid[r][c], c * CELL + CELL/2 - 0.5, r * CELL + CELL/2);
}
}
}
}
// Log-log bar chart of avalanche size distribution.
function drawChart() {
const W = chartCanvas.width, H = chartCanvas.height;
cCtx.clearRect(0, 0, W, H);
cCtx.fillStyle = '#f6f9fc';
cCtx.fillRect(0, 0, W, H);
// Bin counts: bin i covers sizes [2^i, 2^(i+1))
const maxBins = 8;
const bins = new Array(maxBins).fill(0);
avalancheSizes.forEach(s => {
if (s === 0) return;
const b = Math.min(Math.floor(Math.log2(s)), maxBins - 1);
bins[b]++;
});
const maxCount = Math.max(...bins, 1);
const pad = {l:28, r:8, t:8, b:22};
const cW = W - pad.l - pad.r;
const cH = H - pad.t - pad.b;
const bw = cW / maxBins;
// Axes
cCtx.strokeStyle = '#aaa'; cCtx.lineWidth = 1;
cCtx.beginPath();
cCtx.moveTo(pad.l, pad.t); cCtx.lineTo(pad.l, pad.t + cH);
cCtx.lineTo(pad.l + cW, pad.t + cH); cCtx.stroke();
// Bars
bins.forEach((count, i) => {
if (count === 0) return;
const logH = Math.log2(count + 1) / Math.log2(maxCount + 1);
const bH = logH * cH;
const x = pad.l + i * bw + 1;
const y = pad.t + cH - bH;
cCtx.fillStyle = '#4a90d9';
cCtx.fillRect(x, y, bw - 2, bH);
});
// X-axis labels (log2 bin edges)
cCtx.fillStyle = '#555'; cCtx.font = '9px system-ui'; cCtx.textAlign = 'center';
for (let i = 0; i < maxBins; i++) {
const label = i === 0 ? '1' : Math.pow(2,i).toString();
cCtx.fillText(label, pad.l + i * bw + bw/2, pad.t + cH + 14);
}
// Y label
cCtx.save(); cCtx.translate(10, pad.t + cH/2);
cCtx.rotate(-Math.PI/2); cCtx.textAlign = 'center';
cCtx.fillText('count', 0, 0); cCtx.restore();
// Title
cCtx.textAlign = 'center'; cCtx.font = '9px system-ui'; cCtx.fillStyle = '#777';
cCtx.fillText('size (log₂ bins)', pad.l + cW/2, H - 4);
}
// Click on grid to drop grain there
gridCanvas.addEventListener('click', (e) => {
const rect = gridCanvas.getBoundingClientRect();
const scaleX = gridCanvas.width / rect.width;
const scaleY = gridCanvas.height / rect.height;
const c = Math.floor((e.clientX - rect.left) * scaleX / CELL);
const r = Math.floor((e.clientY - rect.top) * scaleY / CELL);
if (r >= 0 && r < N && c >= 0 && c < N) dropGrain(r, c);
});
document.getElementById('drop').addEventListener('click', () => {
dropGrain(Math.floor(N/2), Math.floor(N/2));
});
const autoBtn = document.getElementById('auto');
autoBtn.addEventListener('click', () => {
autoRunning = !autoRunning;
autoBtn.textContent = autoRunning ? 'Stop auto' : 'Auto-drop';
autoBtn.classList.toggle('active', autoRunning);
if (autoRunning) {
function step() {
if (!autoRunning) return;
dropGrain(Math.floor(N/2), Math.floor(N/2));
autoTimer = setTimeout(step, 120);
}
step();
} else {
clearTimeout(autoTimer);
}
});
document.getElementById('reset').addEventListener('click', () => {
autoRunning = false; autoBtn.textContent = 'Auto-drop'; autoBtn.classList.remove('active');
clearTimeout(autoTimer);
grid = Array.from({length:N}, () => new Array(N).fill(0));
avalancheSizes = []; totalDrops = 0;
logEl.innerHTML = ''; statusEl.textContent = 'Ready. Drop a grain to start.';
drawGrid(); drawChart();
});
drawGrid();
drawChart();
Comments
Loading comments...