Learn Creative Coding (#56) - Agent-Based Art: Autonomous Crawlers
Ten episodes into the emergent systems arc. Grid automata (ep047-049), free-moving flocks (ep050-051), continuous chemistry (ep052-053), formal grammars (ep054-055). Every system so far has been about rules applied uniformly -- every cell follows the same update equation, every boid applies the same three forces, every symbol gets replaced by the same grammar. The output is complex but the agents (if you can even call grid cells agents) don't have much individual identity.
Today we flip that. Agent-based art. Autonomous crawlers that move across a canvas, each one making its own decisions about where to go next, and leaving permanent marks behind. The artwork IS the accumulated trail. No grid. No string rewriting. Just walkers and their ink. The agents themselves are invisible -- you never see them directly. You see where they've been.
This is the closest we've gotten to "artificial life as art." The crawler doesn't know it's making art. It just follows its local rules -- turn left, turn right, move forward, deposit color. Thousands of steps later, the canvas is covered in patterns that nobody designed. Not the programmer, not the crawler. The patterns emerged from the interaction between movement rules and the surface they're walking on.
The simplest crawler: random walk
Start with the absolute minimum. An agent has a position (x, y) and a direction. Each frame it moves forward one step, optionally adjusts its direction, and leaves a dot. That's it.
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width = 800;
const H = canvas.height = 800;
// black background -- trails show up as color
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, W, H);
class Crawler {
constructor(x, y) {
this.x = x;
this.y = y;
this.dir = Math.random() * Math.PI * 2;
this.speed = 1.5;
}
step() {
// random direction change
this.dir += (Math.random() - 0.5) * 0.4;
// move forward
this.x += Math.cos(this.dir) * this.speed;
this.y += Math.sin(this.dir) * this.speed;
// wrap edges
if (this.x < 0) this.x += W;
if (this.x >= W) this.x -= W;
if (this.y < 0) this.y += H;
if (this.y >= H) this.y -= H;
}
draw(ctx) {
ctx.fillStyle = 'rgba(255, 220, 180, 0.03)';
ctx.fillRect(this.x - 0.5, this.y - 0.5, 1, 1);
}
}
const crawler = new Crawler(W / 2, H / 2);
function animate() {
for (let i = 0; i < 100; i++) {
crawler.step();
crawler.draw(ctx);
}
requestAnimationFrame(animate);
}
animate();
One agent, one pixel per step, very low opacity (0.03). After ten thousand steps you get a soft cloud centered on the start position. The random walk explores the space slowly, doubling back on itself constantly, building up brightness where it lingers. The density map IS the trail art.
The magic number is the opacity. At 0.03, a single pass is invisible. But where the crawler revisits a spot 30 times, the overlapping dots build up to full brightness. Frequently visited areas glow. Rarely visited areas stay dark. The alpha accumulation turns a random walk into a probability density visualization -- without us computing any statistics at all. The crawler IS the computation.
Bump the direction change from 0.4 to 1.5 and the walk becomes jittery -- short dashes in all directions, filling a tight cluster. Drop it to 0.05 and the walk becomes smooth -- long sweeping curves that eventually cover the whole canvas. The single parameter 0.4 controls whether the output looks like smoke, yarn, or coastlines.
Multiple crawlers: color by identity
One crawler is nice but fifty crawlers is where it gets interesting. Give each one a different hue:
const crawlers = [];
const COUNT = 50;
for (let i = 0; i < COUNT; i++) {
const c = new Crawler(
W / 2 + (Math.random() - 0.5) * 100,
H / 2 + (Math.random() - 0.5) * 100
);
// assign hue based on index
c.hue = (i / COUNT) * 360;
crawlers.push(c);
}
function animate() {
for (let step = 0; step < 50; step++) {
for (const c of crawlers) {
c.step();
ctx.fillStyle = 'hsla(' + c.hue + ', 70%, 55%, 0.015)';
ctx.fillRect(c.x - 0.5, c.y - 0.5, 1, 1);
}
}
requestAnimationFrame(animate);
}
Each crawler paints with its own color but follows the same random walk rules. After a few minutes (yes, these things need patience) the canvas fills with overlapping color clouds. Where two crawlers of complementary hues overlap, you get neutral tones. Where a single crawler dominates, you get saturated color. The result is a soft watercolor painting that nobody designed.
The opacity drop to 0.015 is necessary because we now have 50 crawlers depositing simultaneously. Without it the canvas would blow out to white in seconds. The rule of thumb: total brightness per frame should be roughly constant. 50 crawlers * 0.015 = 0.75 total alpha per frame across all agents, same as 1 crawler * 0.75. This keeps the build-up speed consistent regardless of population size.
Noise-steered crawlers: flow field painting
Random walks produce interesting textures but they're undirected -- no large-scale structure. Replace the random direction change with noise-based steering and you get something completely different:
// using mulberry32 seeded noise from ep024
// or the Perlin noise from ep012
function noiseAngle(x, y, scale) {
// simple value noise for direction
const ix = Math.floor(x / scale);
const iy = Math.floor(y / scale);
const fx = (x / scale) - ix;
const fy = (y / scale) - iy;
function hash(px, py) {
let h = px * 374761393 + py * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
return (h & 0x7fffffff) / 0x7fffffff;
}
const tl = hash(ix, iy);
const tr = hash(ix + 1, iy);
const bl = hash(ix, iy + 1);
const br = hash(ix + 1, iy + 1);
const sx = fx * fx * (3 - 2 * fx);
const sy = fy * fy * (3 - 2 * fy);
const top = tl + (tr - tl) * sx;
const bot = bl + (br - bl) * sx;
return (top + (bot - top) * sy) * Math.PI * 2;
}
class FlowCrawler {
constructor(x, y) {
this.x = x;
this.y = y;
this.speed = 1.2;
}
step() {
// direction from noise field
const angle = noiseAngle(this.x, this.y, 80);
this.x += Math.cos(angle) * this.speed;
this.y += Math.sin(angle) * this.speed;
// wrap
if (this.x < 0) this.x += W;
if (this.x >= W) this.x -= W;
if (this.y < 0) this.y += H;
if (this.y >= H) this.y -= H;
}
}
This is actually the same flow field particle system from episode 11. But the rendering approach is fundamentally different. In ep011, particles had short lifespans and we cleared the canvas between frames -- the output was an animated flow visualization. Here we NEVER clear the canvas. Every step adds to the permanent record. The output is a static image that builds up over time, like a long-exposure photograph of invisible rivers.
The noise scale (80 pixels) determines the grain of the flow pattern. Large scale = long sweeping curves. Small scale = tight curls. The agents follow the same field so their trails are coherent -- parallel paths that curve together, creating the impression of wind, water, or magnetic field lines.
Run 200 crawlers for 50,000 steps each and you get something that looks like it belongs in a gallery. Not because the individual marks are impressive (they're 1-pixel dots at 2% opacity) but because the accumulated structure emerges from millions of tiny deposits all following the same invisible field. The noise field is the composition. The crawlers are the brushstrokes.
DLA: Diffusion-Limited Aggregation
DLA is agent-based art where the agents build a structure by sticking to it. The idea: start with a single seed pixel in the center. Release random walkers from the edges. Each walker wanders randomly until it touches the existing structure, then it freezes permanently and becomes part of the structure. New walkers bounce off existing structure and keep walking until they find an open attachment point.
The result is branching, fractal, lightning-like forms. Coral. Frost on glass. Mineral dendrites. River deltas seen from space.
const W = 400;
const H = 400;
const grid = new Uint8Array(W * H); // 0 = empty, 1 = structure
// seed: single pixel in center
grid[H / 2 * W + W / 2] = 1;
function isNextToStructure(x, y) {
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < W && ny >= 0 && ny < H) {
if (grid[ny * W + nx] === 1) return true;
}
}
}
return false;
}
function runWalker() {
// start from random edge position
let x, y;
const side = Math.floor(Math.random() * 4);
if (side === 0) { x = 0; y = Math.floor(Math.random() * H); }
else if (side === 1) { x = W - 1; y = Math.floor(Math.random() * H); }
else if (side === 2) { x = Math.floor(Math.random() * W); y = 0; }
else { x = Math.floor(Math.random() * W); y = H - 1; }
let steps = 0;
const maxSteps = 50000;
while (steps < maxSteps) {
// random walk
const move = Math.floor(Math.random() * 4);
if (move === 0) x++;
else if (move === 1) x--;
else if (move === 2) y++;
else y--;
// boundary check -- kill if wandered off
if (x < 0 || x >= W || y < 0 || y >= H) return;
// check if adjacent to structure
if (isNextToStructure(x, y)) {
grid[y * W + x] = 1; // stick!
return;
}
steps++;
}
}
// grow the structure
for (let i = 0; i < 15000; i++) {
runWalker();
}
The key insight: walkers that approach from far away are more likely to stick to the outermost tips of the structure, because those tips are the first thing they encounter. This creates a positive feedback loop -- tips grow faster because they catch more walkers, which makes them stick out further, which catches even more walkers. The result is branching. Nobody told the system to branch. The branching emerges from the statistics of random walking near a non-convex boundary.
DLA is slow. Each walker takes thousands of steps before it either sticks or dies. Growing a 400x400 structure with 15,000 particles might take a few seconds. For a 1024x1024 canvas you're looking at much longer. There are optimization tricks -- spawn walkers closer to the existing structure (not from the edge), kill walkers that wander too far, use a bounding circle that grows with the structure -- but the fundamental algorith is simple.
For rendering, color each pixel by when it was added. Early pixels (the core) get one color, late pixels (the tips) get another:
// use a counter array instead of binary grid
const joinOrder = new Uint16Array(W * H);
let nextOrder = 1;
// when a walker sticks:
joinOrder[y * W + x] = nextOrder++;
// render:
function renderDLA() {
const max = nextOrder;
for (let i = 0; i < W * H; i++) {
const order = joinOrder[i];
if (order > 0) {
const t = order / max;
// dark core to bright tips
const r = Math.floor(40 + t * 200);
const g = Math.floor(20 + t * 100);
const b = Math.floor(60 + t * 50);
imgData.data[i * 4 + 0] = r;
imgData.data[i * 4 + 1] = g;
imgData.data[i * 4 + 2] = b;
}
imgData.data[i * 4 + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
}
The temporal coloring reveals the growth history. You can see which branches grew first (dark core) and which grew last (bright tips). The structure tells its own story through color.
Stigmergy: agents that communicate through the canvas
All the crawlers so far are independent. They don't know about each other. They don't react to each other's marks. Stigmergy changes that. The term comes from insect biology -- it's how ants coordinate without talking. Each ant deposits pheromone wherever it walks. Other ants prefer to follow paths with high pheromone concentration. This creates a positive feedback loop: popular paths get more pheromone, which attracts more ants, which deposit more pheromone.
The canvas IS the communication medium. Agents read from the canvas (sense pheromone concentration around them) and write to the canvas (deposit more pheromone). No agent-to-agent communication. No central coordination. Just the surface as a shared blackboard.
// pheromone field
const phero = new Float32Array(W * H);
class AntCrawler {
constructor() {
this.x = Math.random() * W;
this.y = Math.random() * H;
this.dir = Math.random() * Math.PI * 2;
this.speed = 1.5;
}
sense(field, angle, distance) {
// sample pheromone at a point ahead and to one side
const sx = this.x + Math.cos(this.dir + angle) * distance;
const sy = this.y + Math.sin(this.dir + angle) * distance;
const ix = Math.floor(sx + W) % W;
const iy = Math.floor(sy + H) % H;
return field[iy * W + ix];
}
step() {
// sense pheromone in three directions
const senseDistance = 9;
const senseAngle = 0.5; // ~28 degrees
const left = this.sense(phero, -senseAngle, senseDistance);
const center = this.sense(phero, 0, senseDistance);
const right = this.sense(phero, senseAngle, senseDistance);
// turn toward highest concentration
if (center >= left && center >= right) {
// go straight (small random wobble)
this.dir += (Math.random() - 0.5) * 0.1;
} else if (left > right) {
this.dir -= 0.2;
} else {
this.dir += 0.2;
}
// move
this.x += Math.cos(this.dir) * this.speed;
this.y += Math.sin(this.dir) * this.speed;
// wrap
this.x = ((this.x % W) + W) % W;
this.y = ((this.y % H) + H) % H;
// deposit pheromone
const ix = Math.floor(this.x);
const iy = Math.floor(this.y);
if (ix >= 0 && ix < W && iy >= 0 && iy < H) {
phero[iy * W + ix] += 0.5;
}
}
}
The three-sensor model is borrowed directly from ant biology. Three sensors -- left, center, right -- arranged in a fan ahead of the agent. Each sensor reads the pheromone concentration at its position. The agent turns toward the sensor that reads highest. If center is strongest, keep going straight. If left is stronger, turn left. Simple conditional logic, no math beyond sampling.
But you also need pheromone decay. Without it, the field just accumulates forever and the entire canvas saturates. Every frame, decay every cell slightly and diffuse (blur) the field:
function decayAndDiffuse() {
const decay = 0.98;
const next = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
// 3x3 average for diffusion
let sum = 0;
let count = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = (x + dx + W) % W;
const ny = (y + dy + H) % H;
sum += phero[ny * W + nx];
count++;
}
}
next[y * W + x] = (sum / count) * decay;
}
}
phero.set(next);
}
Decay rate 0.98 means 2% loss per frame. After 100 frames without reinforcement, a trail drops to 0.98^100 = 13% of its original strength. This means old abandoned paths fade while active paths stay bright. The system has memory but it's not permanent -- paths need constant traffic to survive. Stop walking a route and it disappears. This is exactly how real ant trails work.
The Physarum simulation
This is the big one. Physarum polycephalum -- the yellow slime mold -- uses the exact same three-sensor mechanism as our ant crawlers. Researchers discovered this in 2010 (Jeff Jones, "Characteristics of Pattern Formation and Evolution in Approximations of Physarum Transport Networks"). The model is: thousands of agent particles sense a chemical trail, turn toward it, move forward, deposit more chemical. The chemical diffuses and decays.
It's our ant crawler code with larger numbers. That's genuinely all it is. The visual output, though, is something else entirely. With 50,000-100,000 agents, the patterns look like neural networks, cosmic filaments, blood vessel networks, mycelium. Stunningly organic.
const W = 512;
const H = 512;
const trail = new Float32Array(W * H);
const AGENT_COUNT = 80000;
const agents = [];
for (let i = 0; i < AGENT_COUNT; i++) {
agents.push({
x: W / 2 + (Math.random() - 0.5) * W * 0.8,
y: H / 2 + (Math.random() - 0.5) * H * 0.8,
dir: Math.random() * Math.PI * 2
});
}
const sensorAngle = 0.65; // ~37 degrees
const sensorDist = 9;
const turnSpeed = 0.45;
const moveSpeed = 1.0;
const depositAmount = 0.8;
const decayRate = 0.96;
function sampleTrail(x, y) {
const ix = Math.floor(x + W) % W;
const iy = Math.floor(y + H) % H;
return trail[iy * W + ix];
}
function stepAgents() {
for (let i = 0; i < AGENT_COUNT; i++) {
const a = agents[i];
// sense
const fL = sampleTrail(
a.x + Math.cos(a.dir - sensorAngle) * sensorDist,
a.y + Math.sin(a.dir - sensorAngle) * sensorDist
);
const fC = sampleTrail(
a.x + Math.cos(a.dir) * sensorDist,
a.y + Math.sin(a.dir) * sensorDist
);
const fR = sampleTrail(
a.x + Math.cos(a.dir + sensorAngle) * sensorDist,
a.y + Math.sin(a.dir + sensorAngle) * sensorDist
);
// turn
if (fC >= fL && fC >= fR) {
// center strongest -- keep going
} else if (fC < fL && fC < fR) {
// both sides stronger -- random choice
a.dir += (Math.random() < 0.5 ? -1 : 1) * turnSpeed;
} else if (fL > fR) {
a.dir -= turnSpeed;
} else {
a.dir += turnSpeed;
}
// move
a.x += Math.cos(a.dir) * moveSpeed;
a.y += Math.sin(a.dir) * moveSpeed;
// wrap
a.x = ((a.x % W) + W) % W;
a.y = ((a.y % H) + H) % H;
// deposit
const ix = Math.floor(a.x);
const iy = Math.floor(a.y);
trail[iy * W + ix] += depositAmount;
}
}
The extra case matters -- when BOTH left and right are stronger than center, the agent picks a random direction instead of just turning left. Without this, agents that approach a trail head-on always turn the same way, creating a bias that breaks the symmetry of the patterns. With random choice, they split evenly, and the network forms properly.
The diffusion and decay are the same as before, but here they're critical to the visual quality:
function diffuseAndDecay() {
const next = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let sum = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
sum += trail[((y + dy + H) % H) * W + ((x + dx + W) % W)];
}
}
next[y * W + x] = (sum / 9) * decayRate;
}
}
trail.set(next);
}
function render() {
const imgData = ctx.createImageData(W, H);
for (let i = 0; i < W * H; i++) {
const v = Math.min(1, trail[i] * 0.3);
// warm palette: dark -> amber -> white
const r = Math.floor(v * v * 255);
const g = Math.floor(v * v * v * 200);
const b = Math.floor(v * v * v * v * 120);
imgData.data[i * 4 + 0] = r;
imgData.data[i * 4 + 1] = g;
imgData.data[i * 4 + 2] = b;
imgData.data[i * 4 + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
}
function loop() {
stepAgents();
diffuseAndDecay();
render();
requestAnimationFrame(loop);
}
loop();
The color mapping uses power curves (v*v, v*v*v, v*v*v*v) to create a nonlinear ramp from dark to warm. Low concentrations are nearly black. Medium concentrations glow amber. High concentrations approach white. This emphasizes the network edges and makes the connecting filaments pop against the dark background.
Run it and watch. For the first few hundred frames, the agents wander randomly and the canvas is mostly dark noise. Then threads start to appear. Agents begin clustering along paths, reinforcing them, the paths get brighter, more agents are attracted, and suddenly the whole canvas is a network of glowing filaments connected by branching junctions. The network self-organizes from complete randomness to a stable transport graph in maybe 500 frames. It looks like footage from a microscope. Or from a telescope -- the cosmic web of dark matter filaments looks almost identcal.
Tuning the Physarum
The parameters change the network's character dramatically:
// tight, dense network
const tightConfig = {
sensorAngle: 0.4,
sensorDist: 5,
turnSpeed: 0.6,
moveSpeed: 0.8,
depositAmount: 1.0,
decayRate: 0.94,
agentCount: 100000
};
// loose, flowing tendrils
const looseConfig = {
sensorAngle: 0.9,
sensorDist: 20,
turnSpeed: 0.25,
moveSpeed: 1.5,
depositAmount: 0.3,
decayRate: 0.985,
agentCount: 40000
};
// chaotic, constantly shifting
const chaoticConfig = {
sensorAngle: 0.3,
sensorDist: 12,
turnSpeed: 0.8,
moveSpeed: 2.0,
depositAmount: 0.5,
decayRate: 0.92,
agentCount: 60000
};
Narrow sensor angle + fast turn = agents lock onto trails aggressively, creating a dense web with short connections. Wide sensor angle + slow turn = agents curve gently, producing flowing rivers and wide channels. High decay + fast movement = nothing sticks, patterns constantly dissolve and reform. Low decay + high deposit = trails are permanent, the network freezes into a stable configuration and barely changes.
The ratio between sensor distance and move speed matters. If agents move further per step than their sensor range, they overshoot trails and can't follow them. The system falls apart into noise. Keep moveSpeed < sensorDist * 0.3 for stable networks. Push moveSpeed > sensorDist * 0.5 and you get interesting instabilities where the network continually breaks and reforms.
Creative variations: seeds and attractors
Instead of distributing agents randomly, seed them in specific shapes. A ring of agents produces a network that fills inward. A grid of point clusters produces a network that connects the clusters with optimal paths (slime mold route optimization!). A text shape produces letters made of living filaments:
// ring initialization
for (let i = 0; i < AGENT_COUNT; i++) {
const angle = Math.random() * Math.PI * 2;
const radius = 150 + Math.random() * 20;
agents.push({
x: W / 2 + Math.cos(angle) * radius,
y: H / 2 + Math.sin(angle) * radius,
dir: angle + Math.PI / 2 + (Math.random() - 0.5) * 0.5
});
}
// point cluster initialization (slime mold city optimization)
const cities = [
[100, 100], [300, 150], [450, 100],
[100, 350], [250, 300], [400, 380],
[200, 500], [350, 450]
];
for (let i = 0; i < AGENT_COUNT; i++) {
const city = cities[Math.floor(Math.random() * cities.length)];
agents.push({
x: city[0] + (Math.random() - 0.5) * 40,
y: city[1] + (Math.random() - 0.5) * 40,
dir: Math.random() * Math.PI * 2
});
}
The city-optimization version is the famous slime mold experiment. Researchers placed food sources on a map of Tokyo and let Physarum grow. The slime mold network converged on something remarkably similar to the actual Tokyo rail network. The organism solved a graph optimization problem that takes humans months of planning -- by just following local chemical gradients. Our 30 lines of code do the same thing. Put food (agent clusters) at cities and the connecting network emerges by itself.
You can also add food sources as fixed high-concentration zones that continuously emit pheromone:
// food sources emit pheromone constantly
const foodSources = [
{ x: 100, y: 100, strength: 3.0 },
{ x: 400, y: 200, strength: 3.0 },
{ x: 250, y: 400, strength: 3.0 }
];
function emitFood() {
for (const food of foodSources) {
for (let dy = -5; dy <= 5; dy++) {
for (let dx = -5; dx <= 5; dx++) {
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 5) {
const ix = (Math.floor(food.x) + dx + W) % W;
const iy = (Math.floor(food.y) + dy + H) % H;
trail[iy * W + ix] += food.strength * (1 - dist / 5);
}
}
}
}
}
Call emitFood() every frame before the agent step. The food points become anchors -- bright nodes that the network grows toward and connects between. Without food sources, the network is self-organizing but aimless. With food, it develops directionality and purpose. The filaments between food sources get thicker (more traffic) while dead-end branches thin out and eventually vanish.
Combining crawlers with cosine palettes
The trail field is just numbers. How you map those numbers to color is a creative decision. The cosine palette from episode 28 produces gorgeous results:
function cosinePalette(t, a, b, c, d) {
return [
a[0] + b[0] * Math.cos(Math.PI * 2 * (c[0] * t + d[0])),
a[1] + b[1] * Math.cos(Math.PI * 2 * (c[1] * t + d[1])),
a[2] + b[2] * Math.cos(Math.PI * 2 * (c[2] * t + d[2]))
];
}
// deep ocean palette
const pa = [0.5, 0.5, 0.5];
const pb = [0.5, 0.5, 0.5];
const pc = [1.0, 1.0, 1.0];
const pd = [0.00, 0.10, 0.20];
function renderWithPalette() {
const imgData = ctx.createImageData(W, H);
for (let i = 0; i < W * H; i++) {
const v = Math.min(1, trail[i] * 0.25);
const rgb = cosinePalette(v, pa, pb, pc, pd);
imgData.data[i * 4 + 0] = Math.floor(rgb[0] * 255);
imgData.data[i * 4 + 1] = Math.floor(rgb[1] * 255);
imgData.data[i * 4 + 2] = Math.floor(rgb[2] * 255);
imgData.data[i * 4 + 3] = 255;
}
ctx.putImageData(imgData, 0, 0);
}
Change the pd offset values and the whole mood shifts. [0.00, 0.10, 0.20] gives you a deep blue-to-cyan ocean. [0.00, 0.33, 0.67] gives you a rainbow spectrum. [0.25, 0.15, 0.00] gives you amber-to-magenta fire. Same network structure, completely different emotional register. The cosine palette is doing the creative work here -- the simulation just provides the values.
Agent lifecycles: birth, wander, die
Living systems have finite lifespans. Adding birth and death to crawlers creates populations that self-regulate:
class LifeCrawler {
constructor(x, y) {
this.x = x;
this.y = y;
this.dir = Math.random() * Math.PI * 2;
this.age = 0;
this.maxAge = 300 + Math.random() * 400;
this.alive = true;
}
step(trailField) {
this.age++;
if (this.age > this.maxAge) {
this.alive = false;
return;
}
// sense and turn (same as Physarum)
const fL = sampleTrail(
this.x + Math.cos(this.dir - 0.5) * 9,
this.y + Math.sin(this.dir - 0.5) * 9
);
const fR = sampleTrail(
this.x + Math.cos(this.dir + 0.5) * 9,
this.y + Math.sin(this.dir + 0.5) * 9
);
if (fL > fR) this.dir -= 0.3;
else if (fR > fL) this.dir += 0.3;
this.x += Math.cos(this.dir) * 1.2;
this.y += Math.sin(this.dir) * 1.2;
this.x = ((this.x % W) + W) % W;
this.y = ((this.y % H) + H) % H;
// deposit -- stronger when young, fading with age
const vigor = 1 - (this.age / this.maxAge);
const ix = Math.floor(this.x);
const iy = Math.floor(this.y);
trail[iy * W + ix] += 0.6 * vigor;
}
}
// population management
let population = [];
const TARGET_POP = 50000;
function managePopulation() {
// remove dead
population = population.filter(c => c.alive);
// spawn replacements at high-trail locations
while (population.length < TARGET_POP) {
// random position biased toward existing trails
let x, y;
for (let attempt = 0; attempt < 10; attempt++) {
x = Math.random() * W;
y = Math.random() * H;
const idx = Math.floor(y) * W + Math.floor(x);
if (trail[idx] > 0.5 || attempt === 9) break;
}
population.push(new LifeCrawler(x, y));
}
}
New agents preferentially spawn at locations with existing trails -- reinforcing active paths. Old agents die and stop depositing -- weakening paths that lose traffic. The population stays at a target size but the individuals constantly cycle. The network stays alive because it's continuously renewed, not because individual agents are immortal.
The age-based deposit decay (vigor = 1 - age/maxAge) is a nice touch. Young crawlers deposit strongly, creating bright fresh paths. Old crawlers deposit weakly, already fading before they die. This creates a visual age gradient along trails -- the head of a growing filament is bright (fresh agents) while the tail fades (old agents dying off). It looks like bioluminescent worms crawling through darkness :-)
Connecting forward
Agent-based art sits in a sweet spot between the grid-based systems from earlier in this arc and the higher-level simulations coming next. Grid automata (Game of Life, continuous automata) operate on fixed cells with fixed neighborhoods. L-systems operate on strings with no spatial awareness. Agents operate in continuous space with adaptive neighborhoods -- they sense their surroundings and respond to what they find.
The Physarum model in particular connects to everything. It's reaction-diffusion (ep052-053) but with discrete agents instead of continuous equations. The pheromone field diffuses and decays just like chemical B in Gray-Scott. The agents are like particles in a flow field (ep011) but they CREATE the field they follow. And the resulting networks look like L-system branches (ep054-055) but they form from the bottom up rather than the top down.
Next we're looking at processes that shape terrain -- erosion, deposition, growth over time. The surface itself becomes the artwork as agents carve and build. Different agents, same principle: local rules, accumulated marks, emergent structure.
't Komt erop neer...
- Agent-based art uses autonomous crawlers that move across a canvas following local rules and leaving permanent marks. The artwork is the accumulated trail -- millions of tiny deposits building up into large-scale structure over thousands of steps
- Random walk crawlers with low-opacity dots create probability density visualizations. Where the walker revisits often, brightness accumulates. The opacity value controls the build-up speed and the overall aesthetic (smoke, yarn, or coastline)
- Noise-steered crawlers follow a flow field (same technique as ep011 particles) but never clear the canvas. The result is a static long-exposure image of invisible rivers. The noise scale determines whether you get sweeping curves or tight curls
- Diffusion-Limited Aggregation (DLA) grows branching fractal structures by random walkers sticking to an existing seed. Tips grow faster because they intercept more walkers -- branching emerges from statistics, not from any branching rule
- Stigmergy lets agents communicate through the canvas. Agents deposit pheromone, other agents sense and follow it. This positive feedback loop creates self-organizing trail networks from nothing. Decay prevents saturation and gives the system memory that fades over time
- The Physarum slime mold model is stigmergy at scale -- 80,000 agents with three sensors (left, center, right) producing neural-network-like filament structures. Same code as the ant crawler, just more agents and tuned parameters. Sensor angle, turn speed, decay rate, and move speed control whether the network is dense, loose, or chaotic
- Initial conditions matter: ring distributions, point clusters, text shapes all produce different network topologies. The city-cluster version recreates the famous slime mold route optimization experiment -- the network self-organizes efficient connections between food sources
Sallukes! Thanks for reading.
X