← Generative art

Wave Function Collapse

A little island map draws itself cell by cell. Every cell starts as a blur of everything it might become. One at a time, the most certain cell picks a terrain, and the rules ripple out to its neighbours: deep sea may only touch shallow water, water only sand, sand only grass, and so on up to the snowy peaks. Loop it and a fresh map is drawn each time.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-wave-function-collapse to a box with a height.

Options

Add any of these attributes to the same element.

data-cellCell size in px. Default 14.
data-speedCells decided per second. Default enough to finish in about 6 seconds.
data-loopHold the finished map, wipe it and draw another.

Undecided cells are drawn as a dim mix of the terrains they could still be, and shrink as their choices narrow. Choices lean towards what the neighbours picked, so you get islands and seas rather than speckle. It’s paused when off screen, and with reduced motion it shows a finished map.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Wave Function Collapse: a little island map that resolves cell by cell.
    // Every cell starts out as "could be anything" (a blur of every terrain
    // it might become). One by one, the most certain cell picks a terrain,
    // and the rules ripple out to its neighbours: deep water may only touch
    // water, water only sand, sand only grass, and so on up to the snowy
    // peaks. The map grows outwards from the cells that have already decided.
    // With data-loop it holds the map, clears it and draws another.
    // Usage: <div data-wave-function-collapse style="height:360px"></div>
    // Optional: data-cell (cell size in px, default 14), data-speed (cells
    // decided per second, default enough to finish in about 6 s),
    // data-loop (clear the map and draw another).
    var DEFAULTS = { cell: 14, hold: 2400, time: 6000 };

    // Terrain, lowest first. A cell may only sit next to its own terrain or
    // the one directly above or below it.
    var TERRAIN = [
      { name: 'deep', color: '#1e3a8a', weight: 1.6 },
      { name: 'water', color: '#3b82f6', weight: 1.3 },
      { name: 'sand', color: '#fcd34d', weight: 0.8 },
      { name: 'grass', color: '#4ade80', weight: 2.6 },
      { name: 'forest', color: '#15803d', weight: 1.8 },
      { name: 'rock', color: '#78716c', weight: 0.9 },
      { name: 'snow', color: '#f5f5f4', weight: 0.5 }
    ];
    var N = TERRAIN.length;

    var css =
      '[data-wave-function-collapse]{position:relative;isolation:isolate;overflow:hidden}' +
      '.wvc-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.slice(1), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    TERRAIN.forEach(function (t) { t.rgb = rgb(t.color); });

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'wvc-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var cell = +d.cell || DEFAULTS.cell;
      var loop = d.loop !== undefined && d.loop !== 'false';
      var w = 0, h = 0, dpr = 1, cols = 0, rows = 0, ox = 0, oy = 0;
      // Each cell keeps the range of terrains it could still be: lo..hi.
      var lo, hi, value, born, left = 0, rate = 1, phase = 'grow', clock = 0, owed = 0, fadeAt = 0;

      function resize() {
        var nw = el.clientWidth, nh = el.clientHeight;
        if (nw === w && nh === h) return false;
        w = nw; h = nh;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        cols = Math.ceil(w / cell);
        rows = Math.ceil(h / cell);
        ox = Math.floor((w - cols * cell) / 2);
        oy = Math.floor((h - rows * cell) / 2);
        return true;
      }

      function start() {
        var n = cols * rows;
        lo = new Int8Array(n);
        hi = new Int8Array(n).fill(N - 1);
        value = new Int8Array(n).fill(-1);
        born = new Float64Array(n);
        left = n;
        rate = +d.speed || n / DEFAULTS.time * 1000;
        phase = 'grow';
        owed = 0;
      }

      // Tighten neighbours' ranges until every cell agrees with its neighbours.
      function propagate(first) {
        var stack = [first];
        while (stack.length) {
          var c = stack.pop(), x = c % cols, y = (c - x) / cols;
          var nb = [x > 0 ? c - 1 : -1, x < cols - 1 ? c + 1 : -1, y > 0 ? c - cols : -1, y < rows - 1 ? c + cols : -1];
          for (var k = 0; k < 4; k++) {
            var o = nb[k];
            if (o < 0) continue;
            var nlo = Math.max(lo[o], lo[c] - 1), nhi = Math.min(hi[o], hi[c] + 1);
            if (nlo !== lo[o] || nhi !== hi[o]) {
              lo[o] = nlo; hi[o] = nhi;
              stack.push(o);
            }
          }
        }
      }

      // Pick the undecided cell with the fewest options left (ties at random).
      function pickCell() {
        var best = 99, picks = [];
        for (var c = 0; c < value.length; c++) {
          if (value[c] >= 0) continue;
          var e = hi[c] - lo[c];
          if (e < best) { best = e; picks = [c]; } else if (e === best) picks.push(c);
        }
        return picks[Math.floor(Math.random() * picks.length)];
      }

      function collapse() {
        var c = pickCell();
        if (c === undefined) return;
        var x = c % cols, y = (c - x) / cols;
        var nb = [x > 0 ? c - 1 : -1, x < cols - 1 ? c + 1 : -1, y > 0 ? c - cols : -1, y < rows - 1 ? c + cols : -1];
        // Weighted choice, nudged towards what the neighbours already chose so
        // the terrain forms lands and seas rather than speckle.
        var weights = [], total = 0;
        for (var t = lo[c]; t <= hi[c]; t++) {
          var wt = TERRAIN[t].weight;
          for (var k = 0; k < 4; k++) if (nb[k] >= 0 && value[nb[k]] === t) wt *= 1.9;
          weights.push(wt);
          total += wt;
        }
        var r = Math.random() * total, pick = lo[c];
        for (var i = 0; i < weights.length; i++) {
          r -= weights[i];
          if (r <= 0) { pick = lo[c] + i; break; }
        }
        lo[c] = hi[c] = value[c] = pick;
        born[c] = clock;
        left--;
        propagate(c);
      }

      function tick(dt) {
        clock += dt;
        if (phase === 'grow') {
          owed += rate * dt / 1000;
          while (owed >= 1 && left > 0) { collapse(); owed--; }
          if (left === 0) { phase = 'hold'; fadeAt = clock + DEFAULTS.hold; }
        } else if (phase === 'hold' && loop && clock >= fadeAt) {
          phase = 'clear';
          fadeAt = clock;
        } else if (phase === 'clear' && clock - fadeAt > 900) {
          start();
        }
      }

      function draw() {
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.fillStyle = '#0f172a';
        ctx.fillRect(0, 0, w, h);
        var wipe = phase === 'clear' ? (clock - fadeAt) / 900 : -1;
        for (var y = 0; y < rows; y++) {
          for (var x = 0; x < cols; x++) {
            var c = y * cols + x, px = ox + x * cell, py = oy + y * cell;
            if (wipe >= 0 && x / cols + y / rows * 0.4 < wipe * 1.4) continue;
            if (value[c] < 0) {
              // Undecided: a dim blend of everything it could still be.
              var r = 0, g = 0, b = 0, n = hi[c] - lo[c] + 1;
              for (var t = lo[c]; t <= hi[c]; t++) { r += TERRAIN[t].rgb[0]; g += TERRAIN[t].rgb[1]; b += TERRAIN[t].rgb[2]; }
              var a = 0.14 + 0.5 * (1 - (n - 1) / (N - 1));
              ctx.fillStyle = 'rgba(' + Math.round(r / n) + ',' + Math.round(g / n) + ',' + Math.round(b / n) + ',' + a.toFixed(2) + ')';
              var inset = cell * 0.18 * (n - 1) / (N - 1) + 1;
              ctx.fillRect(px + inset, py + inset, cell - inset * 2, cell - inset * 2);
            } else {
              // Decided: pops in from a small square to fill the cell.
              var age = Math.min(1, (clock - born[c]) / 260);
              var s = 1 - Math.pow(1 - age, 3);
              var pad = (1 - s) * cell * 0.4;
              ctx.fillStyle = TERRAIN[value[c]].color;
              ctx.fillRect(px + pad, py + pad, cell - pad * 2 + 0.6, cell - pad * 2 + 0.6);
              if (age < 1) {
                ctx.fillStyle = 'rgba(255,255,255,' + (0.6 * (1 - age)).toFixed(2) + ')';
                ctx.fillRect(px + pad, py + pad, cell - pad * 2, cell - pad * 2);
              }
              detail(value[c], px, py, x, y);
            }
          }
        }
      }

      // A little texture on some terrains: ripples, trees and peaks.
      function detail(t, px, py, x, y) {
        var hsh = (x * 73856093) ^ (y * 19349663);
        if (t === 1 && (hsh & 3) === 0) {
          ctx.strokeStyle = 'rgba(255,255,255,.35)';
          ctx.lineWidth = 1;
          ctx.beginPath();
          ctx.moveTo(px + cell * 0.2, py + cell * 0.55);
          ctx.quadraticCurveTo(px + cell * 0.35, py + cell * 0.35, px + cell * 0.5, py + cell * 0.55);
          ctx.quadraticCurveTo(px + cell * 0.65, py + cell * 0.75, px + cell * 0.8, py + cell * 0.55);
          ctx.stroke();
        } else if (t === 4 && (hsh & 1) === 0) {
          ctx.fillStyle = '#166534';
          ctx.beginPath();
          ctx.arc(px + cell / 2, py + cell / 2, cell * 0.3, 0, Math.PI * 2);
          ctx.fill();
          ctx.fillStyle = 'rgba(134,239,172,.45)';
          ctx.beginPath();
          ctx.arc(px + cell * 0.42, py + cell * 0.42, cell * 0.12, 0, Math.PI * 2);
          ctx.fill();
        } else if (t === 5 && (hsh & 3) === 1) {
          ctx.fillStyle = '#57534e';
          ctx.beginPath();
          ctx.moveTo(px + cell * 0.15, py + cell * 0.8);
          ctx.lineTo(px + cell * 0.5, py + cell * 0.2);
          ctx.lineTo(px + cell * 0.85, py + cell * 0.8);
          ctx.fill();
        }
      }

      function still() {
        start();
        while (left > 0) collapse();
        phase = 'hold';
        clock += 1000;
        draw();
      }

      resize();
      start();
      if (reduced()) {
        still();
        if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) still(); }).observe(el);
        return;
      }
      if (window.ResizeObserver) new ResizeObserver(function () { if (resize()) start(); }).observe(el);

      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;
          tick(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-wave-function-collapse]').forEach(function (el) {
        if (el._wvc) return;
        el._wvc = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-wave-function-collapse data-loop style="height: 360px"></div>