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">←</button><span class="val" id="preset-label">a</span><button class="preset-btn" id="preset-next">→</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);