← Generative art

Pixel Fire

The classic demo-scene fire. The bottom row of chunky pixels burns white-hot, and every pixel above copies the one below it, a shade cooler and nudged sideways at random, so flames lick and flicker up the box. Move the pointer over it, or touch it, to stoke a fresh flame at that spot.

Move or drag to stoke the fire

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pixel-fire to a box with a height and a black background.

Options

Add any of these attributes to the same element.

data-pixelSize of one fire pixel in px. Default 4.
data-paletteclassic, purple or blue. Default classic.
data-heightHow tall the flames reach, 0.3 to 2. Default 1.
data-windSideways drift, from -1 (left) to 1 (right). Default 0.
data-fpsFire updates per second. Default 30, for that old-school judder.
data-flareNow and then, put the fire out and let it roar back up.
data-pointer="false"Ignore the pointer.

The palette is the 37 colours used by the fire in the PlayStation port of Doom. The canvas is drawn one dot per fire pixel and scaled up with image-rendering: pixelated, so it stays crisp and costs very little. It’s paused when off screen, and with reduced motion it shows a still frame.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pixel Fire: the classic demo-scene fire. The bottom row of chunky
    // pixels burns white-hot, and each pixel above copies the one below it,
    // a shade cooler and nudged sideways at random, so flames lick and
    // flicker up the box. Move the pointer over it, or touch it, to stoke a
    // fresh flame at that spot.
    // Usage: <div data-pixel-fire style="height:360px;background:#070707"></div>
    // Optional: data-pixel (size of one fire pixel in px, default 4),
    // data-palette ("classic", "purple" or "blue", default classic),
    // data-height (how tall the flames reach, 0.3 to 2, default 1),
    // data-wind (sideways drift, -1 to 1, default 0), data-fps (fire updates
    // per second, default 30), data-pointer="false" (ignore the pointer),
    // data-flare (flare up and die down now and then).
    var DEFAULTS = { pixel: 4, palette: 'classic', height: 1, wind: 0, fps: 30 };

    // The original 37-step palette, from black through red and orange to white.
    var CLASSIC = [
      7, 7, 7, 31, 7, 7, 47, 15, 7, 71, 15, 7, 87, 23, 7, 103, 31, 7, 119, 31, 7, 143, 39, 7, 159, 47, 7,
      175, 63, 7, 191, 71, 7, 199, 71, 7, 223, 79, 7, 223, 87, 7, 223, 87, 7, 215, 95, 7, 215, 95, 7,
      215, 103, 15, 207, 111, 15, 207, 119, 15, 207, 127, 15, 207, 135, 23, 199, 135, 23, 199, 143, 23,
      199, 151, 31, 191, 159, 31, 191, 159, 31, 191, 167, 39, 191, 167, 39, 191, 175, 47, 183, 175, 47,
      183, 183, 47, 183, 183, 55, 207, 207, 111, 223, 223, 159, 239, 239, 199, 255, 255, 255
    ];

    var css =
      '[data-pixel-fire]{position:relative;isolation:isolate;overflow:hidden}' +
      '.pxf-canvas{position:absolute;inset:0;width:100%;height:100%;z-index:-1;image-rendering:pixelated}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // The palette as packed RGBA, with the coolest steps fading to transparent
    // so the box's own background shows through.
    function palette(name) {
      var out = [];
      for (var i = 0; i < 37; i++) {
        var r = CLASSIC[i * 3], g = CLASSIC[i * 3 + 1], b = CLASSIC[i * 3 + 2];
        if (name === 'purple') { var pr = (r + g) / 2, pg = g * 0.6 + b * 0.4; g = pg; b = r; r = pr; }
        else if (name === 'blue') { var br = b, bb = r; g = g * 0.8 + b * 0.2; r = br; b = bb; }
        var a = i === 0 ? 0 : Math.min(255, i * 40);
        out.push([Math.round(r), Math.round(g), Math.round(b), a]);
      }
      return out;
    }

    function build(el) {
      var d = el.dataset;
      var canvas = document.createElement('canvas');
      canvas.className = 'pxf-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.insertBefore(canvas, el.firstChild);
      var ctx = canvas.getContext('2d');
      var pixel = Math.max(1, +d.pixel || DEFAULTS.pixel);
      var pal = palette(d.palette || DEFAULTS.palette);
      var tall = Math.max(0.3, Math.min(2, d.height !== undefined ? +d.height : DEFAULTS.height));
      var wind = Math.max(-1, Math.min(1, +d.wind || DEFAULTS.wind));
      var stepMs = 1000 / (+d.fps || DEFAULTS.fps);
      var usePointer = d.pointer !== 'false';
      var flare = d.flare !== undefined && d.flare !== 'false';
      var W = 0, H = 0, fire = null, img = null, data = null, t = 0;
      var off = document.createElement('canvas'), octx = off.getContext('2d');
      var pointer = null, heat = 36;

      function resize() {
        var nw = Math.max(8, Math.ceil(el.clientWidth / pixel)), nh = Math.max(8, Math.ceil(el.clientHeight / pixel));
        if (nw === W && nh === H) return false;
        W = nw; H = nh;
        off.width = W; off.height = H;
        // The canvas works at fire-pixel size and is stretched up, crisp.
        canvas.width = W; canvas.height = H;
        fire = new Uint8Array(W * H);
        img = octx.createImageData(W, H);
        data = img.data;
        light();
        return true;
      }

      function light() {
        for (var x = 0; x < W; x++) fire[(H - 1) * W + x] = heat;
      }

      // How likely a pixel is to cool by one step as it rises: the flame's
      // height scales with the box and with data-height.
      function coolChance() {
        var reach = H * 0.62 * tall;
        return Math.max(0.05, Math.min(1, 36 / reach));
      }

      function spread() {
        var base = coolChance(), dir = wind < 0 ? -1 : 1, lean = Math.abs(wind) * 0.6;
        for (var x = 0; x < W; x++) {
          // Some columns cool more slowly than others, and the pattern drifts,
          // so the fire rises in tongues rather than one flat band.
          var n = Math.sin(x * 0.11 + t * 1.3) * 0.5 + Math.sin(x * 0.27 - t * 2.1) * 0.3 + Math.sin(x * 0.05 + t * 0.6) * 0.2;
          var cool = base * (1 - n * 0.45);
          for (var y = 1; y < H; y++) {
            var src = y * W + x, v = fire[src];
            if (v === 0) { fire[src - W] = 0; continue; }
            var dx = Math.floor(Math.random() * 3) - 1;
            if (Math.random() < lean) dx -= dir;
            var dst = src - W - dx;
            if (dst < 0) dst += W;
            var c = Math.random() < cool ? 1 : 0;
            fire[dst] = v - c > 0 ? v - c : 0;
          }
        }
        // A stoked flame under the pointer.
        if (pointer) {
          var px = Math.round(pointer.x / pixel), py = Math.round(pointer.y / pixel);
          for (var i = -3; i <= 3; i++) {
            for (var j = -2; j <= 2; j++) {
              var xx = px + i, yy = py + j;
              if (xx < 0 || xx >= W || yy < 0 || yy >= H) continue;
              if (Math.abs(i) + Math.abs(j) < 4) fire[yy * W + xx] = Math.max(fire[yy * W + xx], 36 - Math.abs(i) * 3);
            }
          }
        }
      }

      function render() {
        for (var i = 0, n = W * H; i < n; i++) {
          var c = pal[fire[i]], k = i * 4;
          data[k] = c[0]; data[k + 1] = c[1]; data[k + 2] = c[2]; data[k + 3] = c[3];
        }
        ctx.putImageData(img, 0, 0);
      }

      resize();
      for (var q = 0; q < H * 1.4; q++) spread();
      render();
      if (window.ResizeObserver) new ResizeObserver(function () {
        if (resize()) { for (var q2 = 0; q2 < H * 1.4; q2++) spread(); render(); }
      }).observe(el);
      if (reduced()) return;

      if (usePointer) {
        var at = function (e) {
          var b = el.getBoundingClientRect();
          pointer = { x: e.clientX - b.left, y: e.clientY - b.top };
        };
        el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) at(e); });
        el.addEventListener('pointerdown', at);
        el.addEventListener('pointerleave', function () { pointer = null; });
        el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') pointer = null; });
        el.addEventListener('pointercancel', function () { pointer = null; });
      }

      var last = 0, acc = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(100, now - last) : 16;
          last = now;
          t += dt / 1000;
          if (flare) {
            // Die down to embers and roar back up, on a slow cycle.
            heat = Math.sin(t * 0.55) < -0.6 ? 0 : 36;
            light();
          }
          acc += dt;
          var ran = false;
          while (acc >= stepMs) { acc -= stepMs; spread(); ran = true; }
          if (ran) render();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-pixel-fire]').forEach(function (el) {
        if (el._pxf) return;
        el._pxf = true;
        build(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-pixel-fire style="height: 360px; background: #070707"></div>

<!-- purple, blowing right: -->
<div data-pixel-fire data-palette="purple" data-wind="0.5" style="height: 360px; background: #070707"></div>