← sketches

physarum

Slime mold simulation inspired by Sage Jenson's 36 Points.

Canvas 2D

  const canvas = document.getElementById('canvas') as HTMLCanvasElement;
  const SIM = 300;
  canvas.width = SIM;
  canvas.height = SIM;
  const ctx = canvas.getContext('2d')!;
  const img = ctx.createImageData(SIM, SIM);
  const px = img.data;
  for (let i = 3; i < px.length; i += 4) px[i] = 255;

  let trail = new Float32Array(SIM * SIM);
  let tBuf = new Float32Array(SIM * SIM);

  const MAX_N = 100000;
  const aX = new Float32Array(MAX_N);
  const aY = new Float32Array(MAX_N);
  const aH = new Float32Array(MAX_N);
  const aP = new Float32Array(MAX_N); // progress (for respawn)
  let N = 30000;

  const PI = Math.PI;

  // Color LUT: 256 entries × 3 channels
  const colorLUT = new Uint8Array(256 * 3);
  let lutHueLo = -1;
  let lutHueHi = -1;

  function buildColorLUT(hLo: number, hHi: number) {
    if (hLo === lutHueLo && hHi === lutHueHi) return;
    lutHueLo = hLo;
    lutHueHi = hHi;
    for (let i = 0; i < 256; i++) {
      const v = i / 255;
      let hDeg: number;
      if (hHi >= hLo) {
        hDeg = hLo + v * (hHi - hLo);
      } else {
        const range = (360 - hLo) + hHi;
        hDeg = (hLo + v * range) % 360;
      }
      const h = ((hDeg % 360) + 360) % 360;
      const s = 0.7;
      const l = v * 0.58;
      // HSL to RGB
      const a = s * Math.min(l, 1 - l);
      const f = (n: number) => {
        const k = (n + h / 30) % 12;
        return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
      };
      const idx = i * 3;
      colorLUT[idx]     = (f(0) * 255 + 0.5) | 0;
      colorLUT[idx + 1] = (f(8) * 255 + 0.5) | 0;
      colorLUT[idx + 2] = (f(4) * 255 + 0.5) | 0;
    }
  }

  interface Preset {
    SA: [number, number]; SD: [number, number];
    RA: [number, number]; SS: [number, number];
    oA: number; oR: number;
    dep: number; dec: number;
    hue: [number, number];
    rsp: number;
  }

  const presetKeys = [
    'a','b','c','d','e','f','g','h','i','j','k','l','m',
    'n','o','p','q','r','s','t','u','v','w','x','y','z',
    '0','1','2','3','4','5','6','7','8','9',
  ];

  const presets: Record<string, Preset> = {
    // Classic physarum
    a: { SA: [PI/4, PI/4],   SD: [9, 9],   RA: [PI/4, PI/4],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [30, 50],    rsp: 0 },
    b: { SA: [PI/3, PI/8],   SD: [9, 9],   RA: [PI/4, PI/4],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [200, 260],  rsp: 0 },
    c: { SA: [PI/8, PI/3],   SD: [9, 9],   RA: [PI/4, PI/4],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [0, 30],     rsp: 0 },
    d: { SA: [PI/4, PI/4],   SD: [20, 5],  RA: [PI/4, PI/4],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [120, 180],  rsp: 0 },
    e: { SA: [PI/4, PI/4],   SD: [5, 20],  RA: [PI/4, PI/4],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [270, 320],  rsp: 0 },
    f: { SA: [PI/4, PI/4],   SD: [9, 9],   RA: [PI/2, PI/8],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.90, hue: [40, 65],    rsp: 0 },
    // Varied dynamics
    g: { SA: [PI/4, PI/4],   SD: [9, 9],   RA: [PI/8, PI/2],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.90, hue: [180, 220],  rsp: 0 },
    h: { SA: [PI/4, 0.1],    SD: [9, 18],  RA: [PI/4, PI/8],   SS: [1, 1.5],   oA: 0,  oR: 2,  dep: 0.05, dec: 0.93, hue: [0, 60],     rsp: 0 },
    i: { SA: [0.1, PI/2],    SD: [15, 5],  RA: [PI/6, PI/3],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.91, hue: [200, 280],  rsp: 0 },
    j: { SA: [PI/3, PI/6],   SD: [5, 20],  RA: [PI/3, PI/8],   SS: [0.5, 2],   oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [300, 350],  rsp: 0 },
    k: { SA: [PI/3, 0.2],    SD: [7, 14],  RA: [PI/5, PI/3],   SS: [1.5, 0.5], oA: 0,  oR: 3,  dep: 0.06, dec: 0.91, hue: [80, 140],   rsp: 0 },
    l: { SA: [0.3, 1.2],     SD: [3, 25],  RA: [0.5, 0.2],     SS: [0.8, 2],   oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [20, 50],    rsp: 0 },
    // Using offsets
    m: { SA: [0.8, 0.1],     SD: [6, 12],  RA: [0.3, 0.8],     SS: [1, 1],     oA: 3,  oR: 5,  dep: 0.05, dec: 0.92, hue: [0, 40],     rsp: 0 },
    n: { SA: [PI/6, PI/3],   SD: [12, 8],  RA: [PI/3, PI/6],   SS: [1, 2],     oA: -3, oR: 4,  dep: 0.05, dec: 0.92, hue: [210, 270],  rsp: 0 },
    o: { SA: [PI/2, PI/8],   SD: [15, 3],  RA: [PI/4, PI/3],   SS: [1, 0.5],   oA: 5,  oR: 0,  dep: 0.05, dec: 0.91, hue: [150, 200],  rsp: 0 },
    p: { SA: [0.05, 1.5],    SD: [3, 30],  RA: [0.1, 1.2],     SS: [0.5, 3],   oA: 0,  oR: 8,  dep: 0.04, dec: 0.93, hue: [280, 340],  rsp: 0 },
    q: { SA: [1.5, 0.05],    SD: [30, 3],  RA: [1.2, 0.1],     SS: [3, 0.5],   oA: 5,  oR: 5,  dep: 0.06, dec: 0.90, hue: [40, 80],    rsp: 0 },
    r: { SA: [PI/4, PI/4],   SD: [9, 9],   RA: [0.01, PI],     SS: [1, 1],     oA: -5, oR: 3,  dep: 0.05, dec: 0.92, hue: [0, 360],    rsp: 0 },
    // With respawn
    s: { SA: [PI/4, PI/4],   SD: [1, 30],  RA: [PI/4, PI/4],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [180, 240],  rsp: 500 },
    t: { SA: [PI/6, PI/2],   SD: [10, 10], RA: [PI/2, PI/6],   SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.05, dec: 0.92, hue: [0, 50],     rsp: 300 },
    u: { SA: [PI/4, PI/3],   SD: [8, 15],  RA: [PI/4, PI/3],   SS: [1, 1.5],   oA: 2,  oR: 3,  dep: 0.05, dec: 0.91, hue: [100, 160],  rsp: 400 },
    v: { SA: [PI/3, PI/8],   SD: [12, 6],  RA: [PI/6, PI/2],   SS: [0.8, 1.2], oA: 0,  oR: 0,  dep: 0.05, dec: 0.93, hue: [330, 30],   rsp: 0 },
    w: { SA: [0.5, 1.0],     SD: [5, 20],  RA: [0.3, 0.8],     SS: [1, 2],     oA: -4, oR: 6,  dep: 0.04, dec: 0.92, hue: [50, 100],   rsp: 600 },
    x: { SA: [1.0, 0.2],     SD: [15, 8],  RA: [0.8, 0.3],     SS: [1.5, 0.8], oA: 3,  oR: -3, dep: 0.06, dec: 0.90, hue: [220, 280],  rsp: 0 },
    y: { SA: [PI/4, PI/4],   SD: [10, 10], RA: [PI/4, PI/4],   SS: [1, 1],     oA: 8,  oR: 8,  dep: 0.05, dec: 0.92, hue: [0, 360],    rsp: 0 },
    z: { SA: [0.2, 1.3],     SD: [4, 18],  RA: [0.2, 1.0],     SS: [0.5, 2.5], oA: -3, oR: 5,  dep: 0.05, dec: 0.91, hue: [330, 30],   rsp: 200 },
    // Numeric — extreme / unusual
    '0': { SA: [0.1, 0.1],   SD: [3, 3],   RA: [PI, PI],       SS: [1, 1],     oA: 0,  oR: 0,  dep: 0.08, dec: 0.88, hue: [0, 10],     rsp: 0 },
    '1': { SA: [PI/2, PI/2], SD: [20, 20], RA: [0.1, 0.1],     SS: [2, 2],     oA: 0,  oR: 0,  dep: 0.03, dec: 0.95, hue: [200, 240],  rsp: 0 },
    '2': { SA: [PI/4, PI/4], SD: [9, 9],   RA: [PI/4, PI/4],   SS: [1, 1],     oA: 10, oR: 0,  dep: 0.05, dec: 0.92, hue: [120, 180],  rsp: 0 },
    '3': { SA: [PI/4, PI/4], SD: [9, 9],   RA: [PI/4, PI/4],   SS: [1, 1],     oA: 0,  oR: 10, dep: 0.05, dec: 0.92, hue: [270, 330],  rsp: 0 },
    '4': { SA: [0.5, 1.2],   SD: [5, 25],  RA: [0.3, 1.5],     SS: [0.5, 3],   oA: 5,  oR: 5,  dep: 0.04, dec: 0.93, hue: [0, 360],    rsp: 300 },
    '5': { SA: [1.2, 0.3],   SD: [25, 5],  RA: [1.0, 0.2],     SS: [2, 0.5],   oA: -5, oR: -5, dep: 0.06, dec: 0.90, hue: [40, 70],    rsp: 0 },
    '6': { SA: [PI/6, PI/2], SD: [3, 35],  RA: [PI/8, PI],     SS: [0.5, 3],   oA: 0,  oR: 0,  dep: 0.03, dec: 0.94, hue: [180, 260],  rsp: 500 },
    '7': { SA: [PI/3, PI/6], SD: [15, 7],  RA: [PI/2, PI/8],   SS: [1.5, 0.8], oA: 4,  oR: -4, dep: 0.05, dec: 0.92, hue: [300, 360],  rsp: 0 },
    '8': { SA: [0.8, 0.8],   SD: [10, 10], RA: [0.8, 0.8],     SS: [1.2, 1.2], oA: 3,  oR: 3,  dep: 0.05, dec: 0.92, hue: [60, 120],   rsp: 0 },
    '9': { SA: [0.1, PI/2],  SD: [2, 30],  RA: [0.05, PI],     SS: [0.3, 4],   oA: -8, oR: 8,  dep: 0.04, dec: 0.93, hue: [0, 360],    rsp: 200 },
  };

  let presetIdx = 0;
  const label = document.getElementById('preset-label')!;

  function setSlider(id: string, rawVal: number) {
    const input = document.getElementById(`ctrl-${id}`) as HTMLInputElement | null;
    if (input) {
      input.value = String(rawVal);
      input.dispatchEvent(new Event('input'));
    }
  }

  function applyPreset(p: Preset, key: string) {
    label.textContent = key;
    setSlider('salo', (p.SA[0] * 100) | 0);
    setSlider('sahi', (p.SA[1] * 100) | 0);
    setSlider('sdlo', p.SD[0] | 0);
    setSlider('sdhi', p.SD[1] | 0);
    setSlider('ralo', (p.RA[0] * 100) | 0);
    setSlider('rahi', (p.RA[1] * 100) | 0);
    setSlider('sslo', (p.SS[0] * 10) | 0);
    setSlider('sshi', (p.SS[1] * 10) | 0);
    setSlider('ofsabs', (p.oA * 10) | 0);
    setSlider('ofsrel', (p.oR * 10) | 0);
    setSlider('deposit', (p.dep * 100) | 0);
    setSlider('decay', (p.dec * 100) | 0);
    setSlider('huelo', p.hue[0] | 0);
    setSlider('huehi', p.hue[1] | 0);
    setSlider('respawn', p.rsp | 0);
  }

  function init() {
    for (let i = 0; i < N; i++) {
      aX[i] = Math.random() * SIM;
      aY[i] = Math.random() * SIM;
      aH[i] = Math.random() * PI * 2;
      aP[i] = Math.random() * 1000; // stagger respawn
    }
  }

  function restart() {
    trail.fill(0);
    tBuf.fill(0);
    N = c.agents;
    init();
  }

  const c = controls({
    agents: {
      label: 'agents', min: 5, max: 100, rawValue: 30, value: 30000, step: 5,
      parse: (r: string) => parseInt(r) * 1000,
      format: (v: number) => (v / 1000) + 'K',
      onChange: (v: number) => {
        for (let i = N; i < v; i++) {
          aX[i] = Math.random() * SIM;
          aY[i] = Math.random() * SIM;
          aH[i] = Math.random() * PI * 2;
          aP[i] = Math.random() * 1000;
        }
        N = v;
      },
    },
    decay: { label: 'decay', min: 80, max: 99, rawValue: 92, value: 0.92, parse: (r: string) => parseInt(r) / 100, format: (v: number) => v.toFixed(2) },
    deposit: { label: 'deposit', min: 1, max: 20, rawValue: 5, value: 0.05, parse: (r: string) => parseInt(r) / 100, format: (v: number) => v.toFixed(2) },
    salo: { label: 'sensor \u2220 lo', min: 1, max: 157, rawValue: 79, value: 0.79, parse: (r: string) => parseInt(r) / 100, format: (v: number) => v.toFixed(2) },
    sahi: { label: 'sensor \u2220 hi', min: 1, max: 157, rawValue: 79, value: 0.79, parse: (r: string) => parseInt(r) / 100, format: (v: number) => v.toFixed(2) },
    sdlo: { label: 'sensor dist lo', min: 1, max: 40, value: 9 },
    sdhi: { label: 'sensor dist hi', min: 1, max: 40, value: 9 },
    ralo: { label: 'turn \u2220 lo', min: 1, max: 314, rawValue: 79, value: 0.79, parse: (r: string) => parseInt(r) / 100, format: (v: number) => v.toFixed(2) },
    rahi: { label: 'turn \u2220 hi', min: 1, max: 314, rawValue: 79, value: 0.79, parse: (r: string) => parseInt(r) / 100, format: (v: number) => v.toFixed(2) },
    sslo: { label: 'step lo', min: 1, max: 40, rawValue: 10, value: 1.0, parse: (r: string) => parseInt(r) / 10, format: (v: number) => v.toFixed(1) },
    sshi: { label: 'step hi', min: 1, max: 40, rawValue: 10, value: 1.0, parse: (r: string) => parseInt(r) / 10, format: (v: number) => v.toFixed(1) },
    ofsabs: { label: 'ofs abs', min: -200, max: 200, rawValue: 0, value: 0, parse: (r: string) => parseInt(r) / 10, format: (v: number) => v.toFixed(1) },
    ofsrel: { label: 'ofs rel', min: -200, max: 200, rawValue: 0, value: 0, parse: (r: string) => parseInt(r) / 10, format: (v: number) => v.toFixed(1) },
    huelo: { label: 'hue lo', min: 0, max: 360, value: 30, format: (v: number) => v + '\u00b0' },
    huehi: { label: 'hue hi', min: 0, max: 360, value: 50, format: (v: number) => v + '\u00b0' },
    respawn: { label: 'respawn', min: 0, max: 1000, value: 0, step: 10, format: (v: number) => v === 0 ? 'off' : String(v) },
  }, {
    customRows: [{ html: '<div class="preset-row"><span>preset</span><span class="preset-nav"><button class="preset-btn" id="preset-prev">&larr;</button><span class="val" id="preset-label">a</span><button class="preset-btn" id="preset-next">&rarr;</button></span></div>', index: 0 }],
    onRestart: restart,
    onReset: () => { applyPreset(presets.a, 'a'); presetIdx = 0; restart(); },
  });

  // Preset navigation
  function setPreset(idx: number) {
    presetIdx = ((idx % presetKeys.length) + presetKeys.length) % presetKeys.length;
    const key = presetKeys[presetIdx];
    applyPreset(presets[key], key);
  }

  document.getElementById('preset-prev')?.addEventListener('click', () => setPreset(presetIdx - 1));
  document.getElementById('preset-next')?.addEventListener('click', () => setPreset(presetIdx + 1));

  document.addEventListener('keydown', (e) => {
    if (e.target instanceof HTMLInputElement) return;
    const k = e.key.toLowerCase();
    const idx = presetKeys.indexOf(k);
    if (idx >= 0) setPreset(idx);
    else if (e.key === 'ArrowLeft') setPreset(presetIdx - 1);
    else if (e.key === 'ArrowRight') setPreset(presetIdx + 1);
  });

  function sample(x: number, y: number): number {
    let ix = Math.round(x), iy = Math.round(y);
    if (ix < 0) ix += SIM; else if (ix >= SIM) ix -= SIM;
    if (iy < 0) iy += SIM; else if (iy >= SIM) iy -= SIM;
    return trail[iy * SIM + ix];
  }

  function frame() {
    const dep = c.deposit;
    const saLo = c.salo, saHi = c.sahi;
    const sdLo = c.sdlo, sdHi = c.sdhi;
    const raLo = c.ralo, raHi = c.rahi;
    const ssLo = c.sslo, ssHi = c.sshi;
    const oA = c.ofsabs, oR = c.ofsrel;
    const rsp = c.respawn;

    for (let i = 0; i < N; i++) {
      let x = aX[i], y = aY[i], h = aH[i];

      // Respawn check
      if (rsp > 0) {
        aP[i] += 1;
        if (aP[i] >= rsp) {
          aP[i] = 0;
          x = aX[i] = Math.random() * SIM;
          y = aY[i] = Math.random() * SIM;
          h = aH[i] = Math.random() * PI * 2;
        }
      }

      // Trail value at offset position (36 Points style)
      const dx = Math.cos(h);
      const dy = Math.sin(h);
      const T = Math.min(1, sample(x + oR * dx, y + oR * dy + oA));

      const sa = saLo + T * (saHi - saLo);
      const sd = sdLo + T * (sdHi - sdLo);
      const ra = raLo + T * (raHi - raLo);
      const ss = ssLo + T * (ssHi - ssLo);

      // Sense left, center, right
      const fL = sample(x + Math.cos(h - sa) * sd, y + Math.sin(h - sa) * sd);
      const fC = sample(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
      const fR = sample(x + Math.cos(h + sa) * sd, y + Math.sin(h + sa) * sd);

      // Rotate toward strongest signal
      if (fC > fL && fC > fR) {
        // keep heading
      } else if (fC < fL && fC < fR) {
        aH[i] += Math.random() < 0.5 ? ra : -ra;
      } else if (fL > fR) {
        aH[i] -= ra;
      } else if (fR > fL) {
        aH[i] += ra;
      }

      // Move forward
      let nx = x + Math.cos(aH[i]) * ss;
      let ny = y + Math.sin(aH[i]) * ss;
      if (nx < 0) nx += SIM; else if (nx >= SIM) nx -= SIM;
      if (ny < 0) ny += SIM; else if (ny >= SIM) ny -= SIM;
      aX[i] = nx;
      aY[i] = ny;

      // Deposit
      const dix = Math.round(nx);
      const diy = Math.round(ny);
      if (dix >= 0 && dix < SIM && diy >= 0 && diy < SIM) {
        trail[diy * SIM + dix] += dep;
      }
    }

    // Diffuse (3×3 mean) + decay
    const decay = c.decay;
    for (let y = 0; y < SIM; y++) {
      const rU = (y === 0 ? SIM - 1 : y - 1) * SIM;
      const rC = y * SIM;
      const rD = (y === SIM - 1 ? 0 : y + 1) * SIM;
      for (let x = 0; x < SIM; x++) {
        const xL = x === 0 ? SIM - 1 : x - 1;
        const xR = x === SIM - 1 ? 0 : x + 1;
        tBuf[rC + x] = (
          trail[rU + xL] + trail[rU + x] + trail[rU + xR] +
          trail[rC + xL] + trail[rC + x] + trail[rC + xR] +
          trail[rD + xL] + trail[rD + x] + trail[rD + xR]
        ) / 9 * decay;
      }
    }
    const tmp = trail; trail = tBuf; tBuf = tmp;

    // Render with color LUT
    buildColorLUT(c.huelo, c.huehi);
    for (let i = 0, len = SIM * SIM; i < len; i++) {
      const vi = Math.min(255, trail[i] * 250) | 0;
      const ci = vi * 3;
      const j = i << 2;
      px[j]     = colorLUT[ci];
      px[j + 1] = colorLUT[ci + 1];
      px[j + 2] = colorLUT[ci + 2];
    }
    ctx.putImageData(img, 0, 0);
    requestAnimationFrame(frame);
  }

  init();
  applyPreset(presets.a, 'a');
  requestAnimationFrame(frame);