Slime Mould Trails
Thousands of tiny agents burst out of a ring. Each sniffs ahead and to either side, turns towards the strongest scent and leaves a little more behind it. Their trails knit together into a glowing, branching network, like slime mould hunting for food. Tap or hover to drop some food and watch the network reach for it.
Tap to drop food
How to use it
- Paste the snippet just before
</head>. - Add
data-slime-mould-trailsto a box with a height and a dark background.
Options
Add any of these attributes to the same element.
data-agents | Number of agents. Default one per three trail cells, up to 20,000. |
data-color | Colour of the busiest trails. Default #fde68a. |
data-glow | Colour of the faint trails. Default #a855f7. |
data-scale | Size of one trail cell in px. Default 2. |
data-loop | Start again from a fresh ring every so many seconds. Default 30 when set without a number. |
It’s a model of Physarum polycephalum, the slime mould that famously redrew the Tokyo rail map. It runs on a transparent canvas, paused when off screen. With reduced motion it shows a grown network.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Slime Mould Trails: thousands of tiny agents wander the box, each one
// sniffing ahead, a little to the left and a little to the right, and
// turning towards the strongest scent. As they go they leave scent behind,
// which spreads and fades. Their trails knit together into a glowing,
// branching network, like Physarum slime mould hunting for food.
// Tap or hover to drop food, and the network reaches out for it.
// Drawn on a transparent canvas and paused when off screen.
// Usage: <div data-slime-mould-trails style="height:360px;background:#07040d"></div>
// Optional: data-agents (default one per three trail cells, up to 20,000),
// data-color (brightest trails, default #fde68a), data-glow (faint trails,
// default #a855f7), data-scale (size of a trail cell in px, default 2),
// data-loop (start again from a fresh ring every this many seconds).
var DEFAULTS = { color: '#fde68a', glow: '#a855f7', scale: 2 };
var ANGLE = 0.5, TURN = 0.35, SENSE = 9, DEPOSIT = 1, DECAY = 0.9, SPREAD = 0.4;
var css =
'[data-slime-mould-trails]{position:relative;isolation:isolate;overflow:hidden}' +
'.slm-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function rgb(hex) {
var n = parseInt(hex.replace('#', ''), 16);
return [n >> 16, n >> 8 & 255, n & 255];
}
function build(el) {
var d = el.dataset;
var canvas = document.createElement('canvas');
canvas.className = 'slm-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.insertBefore(canvas, el.firstChild);
var ctx = canvas.getContext('2d');
var hot = rgb(d.color || DEFAULTS.color), cool = rgb(d.glow || DEFAULTS.glow);
var scale = +d.scale || DEFAULTS.scale;
var restart = (+d.loop || (d.loop !== undefined && d.loop !== 'false' ? 30 : 0)) * 1000;
var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, cell = scale;
var trail, spare, ax, ay, aa, img, off = document.createElement('canvas'), octx = off.getContext('2d');
var lut = new Uint32Array(256), clock = 0, food = null, waiting = false;
// Scent strength (0-255) to colour: dark, through the glow, to the hot colour.
for (var i = 0; i < 256; i++) {
var k = i / 255, m = Math.min(1, k * 1.6), hk = Math.max(0, (k - 0.35) / 0.65);
var r = cool[0] + (hot[0] - cool[0]) * hk, g = cool[1] + (hot[1] - cool[1]) * hk, b = cool[2] + (hot[2] - cool[2]) * hk;
lut[i] = ((m * 255) << 24 | Math.round(b) << 16 | Math.round(g) << 8 | Math.round(r)) >>> 0;
}
// Agents start in a ring, facing outwards, and burst out into a web.
function seed() {
var n = +d.agents || Math.round(Math.min(20000, cols * rows / 3));
ax = new Float32Array(n); ay = new Float32Array(n); aa = new Float32Array(n);
var R = Math.min(cols, rows) * 0.32;
for (var j = 0; j < n; j++) {
var a = Math.random() * Math.PI * 2, rr = R * Math.sqrt(Math.random());
ax[j] = cols / 2 + Math.cos(a) * rr;
ay[j] = rows / 2 + Math.sin(a) * rr;
aa[j] = a + (Math.random() - 0.5) * 0.6;
}
trail.fill(0);
clock = 0;
}
function resize() {
var nw = el.clientWidth, nh = el.clientHeight;
if (nw === w && nh === h) return;
w = nw; h = nh;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// Keep the trail map to about 110,000 cells, whatever the size.
cell = Math.max(scale, Math.sqrt(w * h / 110000));
cols = Math.max(20, Math.ceil(w / cell));
rows = Math.max(20, Math.ceil(h / cell));
trail = new Float32Array(cols * rows);
spare = new Float32Array(cols * rows);
off.width = cols; off.height = rows;
img = octx.createImageData(cols, rows);
seed();
}
function smell(x, y) {
var gx = Math.floor(x), gy = Math.floor(y);
if (gx < 0) gx += cols; else if (gx >= cols) gx -= cols;
if (gy < 0) gy += rows; else if (gy >= rows) gy -= rows;
return trail[gy * cols + gx] || 0;
}
function step() {
var n = ax.length;
for (var j = 0; j < n; j++) {
var x = ax[j], y = ay[j], a = aa[j];
var f = smell(x + Math.cos(a) * SENSE, y + Math.sin(a) * SENSE);
var fl = smell(x + Math.cos(a - ANGLE) * SENSE, y + Math.sin(a - ANGLE) * SENSE);
var fr = smell(x + Math.cos(a + ANGLE) * SENSE, y + Math.sin(a + ANGLE) * SENSE);
if (f > fl && f > fr) { /* straight on */ }
else if (f < fl && f < fr) a += (Math.random() < 0.5 ? -1 : 1) * TURN;
else if (fl > fr) a -= TURN;
else if (fr > fl) a += TURN;
a += (Math.random() - 0.5) * 0.1;
x += Math.cos(a); y += Math.sin(a);
if (x < 0) x += cols; else if (x >= cols) x -= cols;
if (y < 0) y += rows; else if (y >= rows) y -= rows;
ax[j] = x; ay[j] = y; aa[j] = a;
var i = (y | 0) * cols + (x | 0);
trail[i] = Math.min(8, trail[i] + DEPOSIT);
}
// Food from the pointer.
if (food) {
for (var fy = -3; fy <= 3; fy++) for (var fx = -3; fx <= 3; fx++) {
var gx = (food.x + fx + cols) % cols, gy = (food.y + fy + rows) % rows;
trail[gy * cols + gx] = 8;
}
}
// The scent spreads (a 3 x 3 blur) and fades.
for (var yy = 0; yy < rows; yy++) {
var up = ((yy + rows - 1) % rows) * cols, mid = yy * cols, dn = ((yy + 1) % rows) * cols;
for (var xx = 0; xx < cols; xx++) {
var l = xx === 0 ? cols - 1 : xx - 1, r = xx === cols - 1 ? 0 : xx + 1;
spare[mid + xx] = ((trail[up + l] + trail[up + xx] + trail[up + r] + trail[mid + l] + trail[mid + xx] +
trail[mid + r] + trail[dn + l] + trail[dn + xx] + trail[dn + r]) / 9 * SPREAD + trail[mid + xx] * (1 - SPREAD)) * DECAY;
}
}
var t = trail; trail = spare; spare = t;
}
function draw() {
var buf = new Uint32Array(img.data.buffer);
for (var j = 0; j < trail.length; j++) {
var v = (1 - Math.exp(-trail[j] * 0.4)) * 290;
buf[j] = lut[v > 255 ? 255 : v | 0];
}
octx.putImageData(img, 0, 0);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, 0, 0, cols * cell, rows * cell);
}
function advance(dt) {
step();
clock += dt;
if (restart && !waiting && clock > restart) {
waiting = true;
anime.animate(canvas, {
opacity: 0, duration: 800, ease: 'inOut(2)',
onComplete: function () {
seed();
waiting = false;
anime.animate(canvas, { opacity: 1, duration: 300 });
}
});
}
}
resize();
if (reduced()) {
// A still frame of the grown network.
for (var k = 0; k < 260; k++) step();
draw();
return;
}
if (window.ResizeObserver) new ResizeObserver(resize).observe(el);
function feed(e) {
var r = el.getBoundingClientRect();
food = { x: Math.floor((e.clientX - r.left) / cell), y: Math.floor((e.clientY - r.top) / cell) };
}
el.addEventListener('pointerdown', function (e) {
feed(e);
// A tap leaves food for a couple of seconds.
clearTimeout(el._slmFood);
if (e.pointerType !== 'mouse') el._slmFood = setTimeout(function () { food = null; }, 2500);
});
el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse') feed(e); });
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') food = null; });
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
advance(dt);
draw();
}
});
// Pause while off screen to save battery.
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
}).observe(el);
}
function init() {
document.querySelectorAll('[data-slime-mould-trails]').forEach(function (el) {
if (el._slm) return;
el._slm = true;
build(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-slime-mould-trails style="height: 360px; background: #07040d"></div>