← Generative art

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

  1. Paste the snippet just before </head>.
  2. Add data-slime-mould-trails to a box with a height and a dark background.

Options

Add any of these attributes to the same element.

data-agentsNumber of agents. Default one per three trail cells, up to 20,000.
data-colorColour of the busiest trails. Default #fde68a.
data-glowColour of the faint trails. Default #a855f7.
data-scaleSize of one trail cell in px. Default 2.
data-loopStart 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.

1 · Snippet
<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>
2 · Your HTML
<div data-slime-mould-trails style="height: 360px; background: #07040d"></div>