Learn Creative Coding (#60) - Mini-Project: Artificial Ecosystem

in StemSocial21 hours ago

Learn Creative Coding (#60) - Mini-Project: Artificial Ecosystem

cc-banner

Fourteen episodes. Fourteen different emergent systems. Grid automata (ep047-049), free-moving flocks (ep050-051), continuous chemistry (ep052-053), formal grammars (ep054-055), autonomous crawlers (ep056), erosion (ep057), swarm intelligence (ep058), wave simulation (ep059). Each one explored a single mechanism -- one type of rule, one type of agent, one type of interaction. And each one produced something complex and beautiful from simple ingredients.

But nature doesn't use just one mechanism. A forest has cellular growth (trees expanding their canopy like a CA), flocking (birds, schools of fish), chemical signaling (pheromones, root networks), physics (wind, gravity, water flow), and agent behavior (animals making decisions) all happening simultaneously in the same space. The real magic isn't any one system -- it's the interaction between systems. Predators chase prey that graze plants that grow according to light and nutrients. Remove any layer and the whole thing changes.

So this mini-project combines everything. We're building an artificial ecosystem -- a digital terrarium with three trophic levels: plants (producers), herbivores (consumers), and predators (top consumers). Plants grow using cellular automata rules. Herbivores flock and graze. Predators hunt herbivores. Energy flows up the food chain. Populations oscillate in classic predator-prey dynamics. And it all runs in one Canvas simulation where you can watch the balance of nature play out in real time.

This is the most ambitious thing we've built in this series so far. Let's see if we can make it live :-)

The world: a shared grid

Everything exists in one space. The grid holds plant density (how much food is in each cell). Herbivores and predators are free-moving agents on top of that grid. The grid affects the agents (herbivores eat from it) and agents affect the grid (grazing removes plant density).

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width = 600;
const H = canvas.height = 600;

// plant layer: each cell holds a growth value (0 = barren, 1 = fully grown)
const plants = new Float32Array(W * H);
const growthRate = 0.003;  // how fast plants regrow
const maxPlantEnergy = 1.0;

// initial planting: random patches
for (let i = 0; i < W * H; i++) {
  plants[i] = Math.random() < 0.4 ? Math.random() * 0.8 : 0;
}

The plant layer is a continuous field, not binary. Each cell stores a value from 0 to 1 representing how much biomass is there. Plants regrow slowly (0.003 per frame) until they hit 1.0. Herbivores can graze a cell partially -- eating 0.3 from a cell that has 0.7 leaves it at 0.4, not empty. This partial grazing creates gradients across the landscape: fully grazed areas, partially grazed transition zones, and lush untouched patches. The herbivores have to decide where to go based on these gradients.

Plant growth: cellular automata meets diffusion

Plants don't just grow in place -- they spread. A cell next to a lush neighbor grows faster than an isolated cell (seeds and root networks propagate from existing plants). This is a CA-like rule: growth rate depends on the state of neighbors.

function growPlants() {
  for (let y = 1; y < H - 1; y++) {
    for (let x = 1; x < W - 1; x++) {
      const idx = y * W + x;

      if (plants[idx] >= maxPlantEnergy) continue;

      // base growth
      let growth = growthRate;

      // neighbor bonus: more nearby plants = faster growth (seed dispersal)
      const neighbors = plants[idx - 1] + plants[idx + 1] +
                        plants[idx - W] + plants[idx + W];
      const neighborBonus = neighbors * 0.001;

      // light competition: very dense neighbors slightly reduce growth
      if (neighbors > 3.5) {
        growth *= 0.5;  // canopy shading
      }

      plants[idx] = Math.min(maxPlantEnergy, plants[idx] + growth + neighborBonus);
    }
  }
}

Two competing effects. Nearby plants help growth (seed dispersal, mycorrhizal networks) up to a point. But very dense neighborhoods suppress growth (canopy shading -- not enough light for everyone). This creates a natural equilibrium density. An empty patch fills in slowly from the edges, accelerating as more plants establish, then leveling off at the carrying capacity. A grazed-bare patch regrows from surrounding lush zones inward, creating visible wavefronts of regrowth.

The neighbors > 3.5 threshold means a cell surrounded by nearly maxed-out neighbors gets its growth rate halved. This prevents the entire canvas from hitting maximum density instantly. In practice the landscape settles into a patchwork: some areas fully grown, some regrowing, some freshly grazed. Dynamic equilibrium, not uniform green.

Herbivores: boids that eat

The herbivores are boids from episode 50 with added biology. They flock, they sense food (plant density), they have energy (decreases each frame, replenished by eating), they reproduce when fat, and they die when starved.

class Herbivore {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = (Math.random() - 0.5) * 2;
    this.vy = (Math.random() - 0.5) * 2;
    this.energy = 50 + Math.random() * 30;
    this.maxEnergy = 120;
    this.age = 0;
    this.maxSpeed = 1.6;
  }

  sense(plants, herbivores, predators) {
    let fx = 0, fy = 0;

    // food gradient: steer toward nearby plant-rich cells
    const senseRadius = 30;
    let bestFoodX = 0, bestFoodY = 0, bestFood = 0;

    for (let angle = 0; angle < Math.PI * 2; angle += 0.5) {
      const sx = Math.floor(this.x + Math.cos(angle) * senseRadius);
      const sy = Math.floor(this.y + Math.sin(angle) * senseRadius);
      if (sx >= 0 && sx < W && sy >= 0 && sy < H) {
        const food = plants[sy * W + sx];
        if (food > bestFood) {
          bestFood = food;
          bestFoodX = sx - this.x;
          bestFoodY = sy - this.y;
        }
      }
    }

    if (bestFood > 0.1) {
      const dist = Math.sqrt(bestFoodX * bestFoodX + bestFoodY * bestFoodY);
      if (dist > 0) {
        fx += (bestFoodX / dist) * 0.3;
        fy += (bestFoodY / dist) * 0.3;
      }
    }

    // flocking (simplified boids rules from ep050)
    let cohX = 0, cohY = 0, sepX = 0, sepY = 0, aliX = 0, aliY = 0;
    let flockCount = 0;

    for (const other of herbivores) {
      if (other === this) continue;
      const dx = other.x - this.x;
      const dy = other.y - this.y;
      const d2 = dx * dx + dy * dy;

      if (d2 < 2500) {  // within 50px
        flockCount++;
        cohX += other.x;
        cohY += other.y;
        aliX += other.vx;
        aliY += other.vy;

        if (d2 < 400) {  // within 20px -- too close
          sepX -= dx;
          sepY -= dy;
        }
      }
    }

    if (flockCount > 0) {
      cohX = cohX / flockCount - this.x;
      cohY = cohY / flockCount - this.y;
      fx += cohX * 0.005;
      fy += cohY * 0.005;
      fx += sepX * 0.08;
      fy += sepY * 0.08;
      fx += (aliX / flockCount) * 0.03;
      fy += (aliY / flockCount) * 0.03;
    }

    // predator avoidance: flee from nearby predators
    for (const pred of predators) {
      const dx = pred.x - this.x;
      const dy = pred.y - this.y;
      const d2 = dx * dx + dy * dy;

      if (d2 < 6400) {  // within 80px -- danger zone
        const dist = Math.sqrt(d2);
        fx -= (dx / dist) * 1.2;
        fy -= (dy / dist) * 1.2;
      }
    }

    return { fx, fy };
  }
}

The key design decision: herbivores balance three competing drives. Food-seeking steers them toward lush patches. Flocking keeps them in groups (safety in numbers -- harder for a predator to pick one off). Predator avoidance sends them fleeing. When there's no predator nearby, food-seeking and flocking dominate. When a predator appears, avoidance overrides everything. This creates realistic behavior: herds grazing calmly, then suddenly bolting when a predator approaches, then regrouping once safe.

The senseRadius of 30 pixels means herbivores can only sense food in their immediate vicinity. They're not omniscient -- they have to explore to find the best patches. This limited sensing creates spatial dynamics: a herd might deplete their local area and then drift to find richer ground, leaving the old area to regrow.

The metabolism: energy drives everything

Every frame, every creature burns energy just by existing. Moving costs extra. Eating replenishes. Below zero = death. Above a threshold = reproduction.

function updateHerbivore(h, plants, herbivores, predators) {
  h.age++;

  // metabolic cost
  h.energy -= 0.15;

  // sense environment and get steering forces
  const forces = h.sense(plants, herbivores, predators);

  // apply forces
  h.vx += forces.fx;
  h.vy += forces.fy;

  // clamp speed
  const speed = Math.sqrt(h.vx * h.vx + h.vy * h.vy);
  if (speed > h.maxSpeed) {
    h.vx = (h.vx / speed) * h.maxSpeed;
    h.vy = (h.vy / speed) * h.maxSpeed;
  }

  // move
  h.x += h.vx;
  h.y += h.vy;

  // wrap around edges
  h.x = ((h.x % W) + W) % W;
  h.y = ((h.y % H) + H) % H;

  // eat: consume plant at current position
  const ix = Math.floor(h.x);
  const iy = Math.floor(h.y);
  const idx = iy * W + ix;

  if (plants[idx] > 0.1) {
    const eaten = Math.min(plants[idx], 0.15);
    plants[idx] -= eaten;
    h.energy += eaten * 40;  // convert plant matter to herbivore energy
    h.energy = Math.min(h.energy, h.maxEnergy);
  }

  // death check
  if (h.energy <= 0) return 'dead';

  // reproduction: if well-fed and old enough
  if (h.energy > 90 && h.age > 100 && Math.random() < 0.01) {
    h.energy -= 40;  // birth costs energy
    return 'reproduce';
  }

  return 'alive';
}

The energy conversion ratio matters enormously for ecosystem stability. eaten * 40 means eating 0.15 plant matter gives 6 energy. The metabolic cost is 0.15 per frame. So a herbivore in a lush area (always grazing) gains 6 - 0.15 = 5.85 net energy per frame. A herbivore in a barren area loses 0.15 per frame. With 50 starting energy, a herbivore in a desert survives about 333 frames before dying. That's enough time to wander and find food, but not enough to survive indefinitely without eating.

Reproduction at 90 energy with a 1% chance per frame means a well-fed herbivore reproduces roughly once every 100 frames. The 40-energy birth cost prevents runaway population growth -- a parent needs to accumulate 40 energy above the threshold before reproducing again. This creates natural boom-bust dynamics: when food is plentiful, herbivores reproduce rapidly; when food runs out, population crashes.

Predators: the hunters

Predators are similar to herbivores but they eat herbivores instead of plants. They're faster, fewer in number, and each kill gives them a big energy boost. Classic Lotka-Volterra dynamics: predator population lags behind prey population by about a quarter cycle.

class Predator {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.vx = (Math.random() - 0.5) * 2;
    this.vy = (Math.random() - 0.5) * 2;
    this.energy = 80 + Math.random() * 40;
    this.maxEnergy = 200;
    this.age = 0;
    this.maxSpeed = 2.2;  // faster than herbivores
    this.huntCooldown = 0;
  }

  hunt(herbivores) {
    let fx = 0, fy = 0;

    // find nearest herbivore within detection range
    let nearestDist = Infinity;
    let nearestHerb = null;

    for (const herb of herbivores) {
      const dx = herb.x - this.x;
      const dy = herb.y - this.y;
      const d2 = dx * dx + dy * dy;

      if (d2 < 10000 && d2 < nearestDist) {  // within 100px
        nearestDist = d2;
        nearestHerb = herb;
      }
    }

    if (nearestHerb) {
      // chase
      const dx = nearestHerb.x - this.x;
      const dy = nearestHerb.y - this.y;
      const dist = Math.sqrt(dx * dx + dy * dy);
      fx = (dx / dist) * 0.6;
      fy = (dy / dist) * 0.6;
    } else {
      // wander when no prey visible
      fx = (Math.random() - 0.5) * 0.3;
      fy = (Math.random() - 0.5) * 0.3;
    }

    // mild separation from other predators (territorial)
    return { fx, fy };
  }
}

function updatePredator(p, herbivores, predators) {
  p.age++;
  p.energy -= 0.2;  // higher metabolic rate than herbivores
  if (p.huntCooldown > 0) p.huntCooldown--;

  const forces = p.hunt(herbivores);

  p.vx += forces.fx;
  p.vy += forces.fy;

  const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
  if (speed > p.maxSpeed) {
    p.vx = (p.vx / speed) * p.maxSpeed;
    p.vy = (p.vy / speed) * p.maxSpeed;
  }

  p.x += p.vx;
  p.y += p.vy;
  p.x = ((p.x % W) + W) % W;
  p.y = ((p.y % H) + H) % H;

  // try to catch herbivores
  if (p.huntCooldown <= 0) {
    for (let i = herbivores.length - 1; i >= 0; i--) {
      const dx = herbivores[i].x - p.x;
      const dy = herbivores[i].y - p.y;
      if (dx * dx + dy * dy < 64) {  // within 8px -- caught!
        p.energy += 50;
        p.energy = Math.min(p.energy, p.maxEnergy);
        p.huntCooldown = 30;  // digest
        herbivores.splice(i, 1);
        break;
      }
    }
  }

  if (p.energy <= 0) return 'dead';

  if (p.energy > 140 && p.age > 200 && Math.random() < 0.005) {
    p.energy -= 60;
    return 'reproduce';
  }

  return 'alive';
}

Predators are faster (2.2 vs 1.6 max speed) so they can catch herbivores. But the speed advantage isn't huge -- a herbivore that spots the predator early and flees has a decent chance of escaping, especially in a flock where the confusion effect makes it harder for the predator to target one individual. The huntCooldown of 30 frames after a kill means a predator can't chain-kill an entire herd in a single pass. It has to digest, giving survivors time to scatter.

The reproduction threshold is harder to reach (140 energy vs 90 for herbivores) and the reproduction chance is lower (0.5% vs 1% per frame). This ensures predators stay less numerous than herbivores -- as it should be. Top predators in real ecosystems are always rare relative to their prey. If predators reproduced too fast, they'd eat all the herbivores, then starve en masse, and the ecosystem would collapse rather than oscillate.

The main loop: tying it all together

let herbivores = [];
let predators = [];

// initial populations
for (let i = 0; i < 80; i++) {
  herbivores.push(new Herbivore(Math.random() * W, Math.random() * H));
}
for (let i = 0; i < 8; i++) {
  predators.push(new Predator(Math.random() * W, Math.random() * H));
}

function simulate() {
  // 1. grow plants
  growPlants();

  // 2. update herbivores
  const newHerbivores = [];
  for (let i = herbivores.length - 1; i >= 0; i--) {
    const result = updateHerbivore(herbivores[i], plants, herbivores, predators);
    if (result === 'dead') {
      herbivores.splice(i, 1);
    } else if (result === 'reproduce') {
      newHerbivores.push(new Herbivore(
        herbivores[i].x + (Math.random() - 0.5) * 20,
        herbivores[i].y + (Math.random() - 0.5) * 20
      ));
    }
  }
  herbivores.push(...newHerbivores);

  // 3. update predators
  const newPredators = [];
  for (let i = predators.length - 1; i >= 0; i--) {
    const result = updatePredator(predators[i], herbivores, predators);
    if (result === 'dead') {
      predators.splice(i, 1);
    } else if (result === 'reproduce') {
      newPredators.push(new Predator(
        predators[i].x + (Math.random() - 0.5) * 30,
        predators[i].y + (Math.random() - 0.5) * 30
      ));
    }
  }
  predators.push(...newPredators);
}

The simulation order matters. Plants grow first (they don't depend on agent positions this frame). Then herbivores update -- they eat from the plant grid and potentially die or reproduce. Then predators update -- they eat herbivores (removing them from the array) and potentially die or reproduce. This order means a herbivore that dies to a predator this frame already ate and deposited its grazing effect on the plant grid. Its contribution to the ecosystem this frame is preserved.

Starting with 80 herbivores and 8 predators (10:1 ratio) gives a stable-ish initial condition. Too many predators at start = they eat everything immediately and all die. Too few = herbivore population explodes before predators can catch up, overgrazes the plants, then crashes. The 10:1 ratio gives both populations time to find their rhythm.

Rendering: seeing the ecosystem

Each layer needs distinct visual treatment so you can read the state at a glance. Plants as a green field, herbivores as warm-colored triangles, predators as red sharp triangles. The plant field opacity indicates density.

function render() {
  // plant layer
  const imgData = ctx.createImageData(W, H);
  for (let i = 0; i < W * H; i++) {
    const p = plants[i];
    imgData.data[i * 4 + 0] = Math.floor(8 + p * 20);
    imgData.data[i * 4 + 1] = Math.floor(15 + p * 100);
    imgData.data[i * 4 + 2] = Math.floor(5 + p * 15);
    imgData.data[i * 4 + 3] = 255;
  }
  ctx.putImageData(imgData, 0, 0);

  // herbivores as triangles pointing in movement direction
  ctx.fillStyle = '#ddaa44';
  for (const h of herbivores) {
    const angle = Math.atan2(h.vy, h.vx);
    ctx.save();
    ctx.translate(h.x, h.y);
    ctx.rotate(angle);
    ctx.beginPath();
    ctx.moveTo(5, 0);
    ctx.lineTo(-3, -3);
    ctx.lineTo(-3, 3);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  // predators as larger red triangles
  ctx.fillStyle = '#cc3322';
  for (const p of predators) {
    const angle = Math.atan2(p.vy, p.vx);
    ctx.save();
    ctx.translate(p.x, p.y);
    ctx.rotate(angle);
    ctx.beginPath();
    ctx.moveTo(8, 0);
    ctx.lineTo(-5, -4);
    ctx.lineTo(-5, 4);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }
}

function loop() {
  simulate();
  render();
  requestAnimationFrame(loop);
}

loop();

Triangles pointing in movement direction gives you instant readability -- you can see which way each creature is heading, whether herds are moving together or scattering, whether a predator is chasing or wandering. The size difference (predators are bigger) helps distinguish them at a glance even when populations are dense.

The plant color ramp goes from very dark green (nearly black) at 0 to brighter green at 1.0. This means grazed areas are dark voids in the landscape. You can literally see the herds' grazing patterns -- trails of darkness behind a moving flock, circular grazed zones around where they lingered, and bright green patches they haven't discovered yet.

Population tracking: the predator-prey graph

The classic Lotka-Volterra signature: prey and predator populations oscillate out of phase. Prey peaks first, then predators peak (because more food means more predators survive and reproduce). Then prey crashes (overhunted), then predators crash (starvation). Then prey recovers (no predators to stop them), then predators recover (abundant prey again). The cycle repeats.

Let's add a population graph overlay:

const historyLength = 400;
const herbHistory = new Array(historyLength).fill(0);
const predHistory = new Array(historyLength).fill(0);
const plantHistory = new Array(historyLength).fill(0);
let historyIdx = 0;

function recordPopulations() {
  herbHistory[historyIdx % historyLength] = herbivores.length;
  predHistory[historyIdx % historyLength] = predators.length;

  // average plant density
  let totalPlant = 0;
  for (let i = 0; i < W * H; i++) totalPlant += plants[i];
  plantHistory[historyIdx % historyLength] = totalPlant / (W * H);

  historyIdx++;
}

function renderGraph() {
  const gx = 10;
  const gy = H - 110;
  const gw = 180;
  const gh = 100;

  // semi-transparent background
  ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
  ctx.fillRect(gx, gy, gw, gh);

  // find max for scaling
  const maxHerb = Math.max(...herbHistory, 1);
  const maxPred = Math.max(...predHistory, 1);

  // draw herbivore population (yellow line)
  ctx.strokeStyle = '#ddaa44';
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  for (let i = 0; i < historyLength; i++) {
    const idx = (historyIdx + i) % historyLength;
    const x = gx + (i / historyLength) * gw;
    const y = gy + gh - (herbHistory[idx] / maxHerb) * gh * 0.9;
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();

  // draw predator population (red line)
  ctx.strokeStyle = '#cc3322';
  ctx.beginPath();
  for (let i = 0; i < historyLength; i++) {
    const idx = (historyIdx + i) % historyLength;
    const x = gx + (i / historyLength) * gw;
    const y = gy + gh - (predHistory[idx] / maxPred) * gh * 0.9;
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();

  // plant density (green, subtle)
  ctx.strokeStyle = '#338833';
  ctx.lineWidth = 1;
  ctx.beginPath();
  for (let i = 0; i < historyLength; i++) {
    const idx = (historyIdx + i) % historyLength;
    const x = gx + (i / historyLength) * gw;
    const y = gy + gh - plantHistory[idx] * gh * 0.9;
    if (i === 0) ctx.moveTo(x, y);
    else ctx.lineTo(x, y);
  }
  ctx.stroke();
}

The graph is the proof that your ecosystem is working. If you see smooth sinusoidal oscillations (prey peak followed by predator peak, quarter-cycle offset), congrats -- you built a functioning Lotka-Volterra system from scratch. If you see chaotic crashes to zero followed by recovery, your parameters need tuning. If both populations flatline at zero, your predators are too efficient. If both grow without bound, your plant growth rate is too high and nothing limits the herbivores.

This graph is also where you notice long-term trends. Maybe the oscillations are damping -- each cycle smaller than the last -- converging on a stable equilibrium. Or maybe they're amplifying -- each crash goes lower, each peak goes higher -- heading for extinction. The parameters need to produce neutral oscillations (constant amplitude) for a stable ecosystem. In practice that's hard to achieve exactly, so we add some stabilizing mechanisms.

Stabilizing the ecosystem

Raw Lotka-Volterra dynamics are fragile. Small parameter changes can tip the system from stable oscillation to extinction spiral. Real ecosystems are more robust because they have spatial structure, refugia (places prey can hide), density-dependent effects, and stochastic variation. Let's add some of those.

// spatial refugia: some areas grow plants faster (safe havens)
const refugia = [];
for (let i = 0; i < 5; i++) {
  refugia.push({
    x: Math.random() * W,
    y: Math.random() * H,
    radius: 40 + Math.random() * 30
  });
}

function isInRefugia(x, y) {
  for (const r of refugia) {
    const dx = x - r.x;
    const dy = y - r.y;
    if (dx * dx + dy * dy < r.radius * r.radius) return true;
  }
  return false;
}

// density-dependent reproduction: harder to reproduce when population is high
function reproductionChance(currentPop, maxPop) {
  const ratio = currentPop / maxPop;
  if (ratio > 0.8) return 0.001;  // heavily suppressed
  if (ratio > 0.5) return 0.005;
  return 0.01;  // normal rate
}

// predator satiation: once a predator eats, it slows down (digesting)
function updatePredatorWithSatiation(p, herbivores) {
  // when hunt cooldown is active, predator moves at half speed
  const speedMult = p.huntCooldown > 0 ? 0.5 : 1.0;

  p.vx *= speedMult;
  p.vy *= speedMult;

  // rest of the update same as before...
}

Refugia are critical. Without them, a small prey population can be hunted to zero because predators search the entire canvas uniformly. With refugia (dense plant patches where herbivores can hide and graze safely), there's always a seed population that survives even the worst predator boom. When predators starve and crash, those survivors in refugia repopulate the world.

Density-dependent reproduction prevents runaway growth. As herbivore population approaches a soft cap, reproduction rate drops. This means even without predators, the herbivore population stabilizes instead of growing infinitely. Same for predators. Real ecosystems have this -- resource competition, disease, territorial behavior -- all act as density-dependent brakes.

Predator satiation is the simplest version of a "functional response" in ecology. A predator that just ate is less dangerous (digesting, slower). This prevents a single predator from slicing through an entire herd in seconds. It gives prey in a group a chance to escape while the predator is occupied with its catch.

Seasonal variation: changing the rules over time

Real ecosystems have seasons. Plant growth varies sinusoidally over time -- lush summers, bare winters. This adds another oscillation on top of the predator-prey cycle and makes the dynamics more complex and visually interesting.

let frame = 0;
const seasonLength = 600;  // frames per full year

function getSeasonalGrowth() {
  // sine wave: peaks at summer (frame % seasonLength == seasonLength/2)
  const phase = (frame % seasonLength) / seasonLength * Math.PI * 2;
  const seasonal = 0.5 + 0.5 * Math.sin(phase);  // 0 to 1

  // map to growth rate: winter = 0.001, summer = 0.005
  return 0.001 + seasonal * 0.004;
}

function growPlantsWithSeasons() {
  const rate = getSeasonalGrowth();

  for (let y = 1; y < H - 1; y++) {
    for (let x = 1; x < W - 1; x++) {
      const idx = y * W + x;
      if (plants[idx] >= maxPlantEnergy) continue;

      let growth = rate;

      // refugia get bonus growth even in winter
      if (isInRefugia(x, y)) {
        growth += 0.002;
      }

      const neighbors = plants[idx - 1] + plants[idx + 1] +
                        plants[idx - W] + plants[idx + W];
      growth += neighbors * 0.0005;

      if (neighbors > 3.5) growth *= 0.5;

      plants[idx] = Math.min(maxPlantEnergy, plants[idx] + growth);
    }
  }
}

During winter (low growth), plant density drops as herbivores continue grazing but regrowth slows. Herbivore population declines through starvation. Predators then decline (fewer prey). When summer returns, plants explode back, herbivores recover, and the cycle resumes. Over multiple years you see a lovely multi-frequency oscillation pattern in the population graph -- the fast predator-prey cycle modulated by the slow seasonal cycle. It's mesmerizing to watch the graph draw itself.

The refugia bonus growth during winter means those safe havens stay green year-round -- oases of food that support a minimal herbivore population through the lean months. This prevents winter from wiping out herbivores entirely (which would crash the whole system).

Visual polish: making it beautiful

Let's upgrade the rendering to make the ecosystem visually compelling. Energy-based coloring for agents, glow effects on the plant layer, and territory trails.

function renderEcosystem() {
  // plant layer with warm-cool gradient
  const imgData = ctx.createImageData(W, H);
  for (let i = 0; i < W * H; i++) {
    const p = plants[i];
    // lush green when full, brown when bare
    const green = Math.floor(20 + p * 120);
    const red = Math.floor(15 + (1 - p) * 25 + p * 10);
    const blue = Math.floor(5 + p * 10);
    imgData.data[i * 4 + 0] = red;
    imgData.data[i * 4 + 1] = green;
    imgData.data[i * 4 + 2] = blue;
    imgData.data[i * 4 + 3] = 255;
  }
  ctx.putImageData(imgData, 0, 0);

  // refugia borders (subtle dotted outline)
  ctx.strokeStyle = 'rgba(100, 200, 80, 0.3)';
  ctx.setLineDash([4, 4]);
  for (const r of refugia) {
    ctx.beginPath();
    ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
    ctx.stroke();
  }
  ctx.setLineDash([]);

  // herbivores: color based on energy level
  for (const h of herbivores) {
    const energyRatio = h.energy / h.maxEnergy;
    const r = Math.floor(200 + energyRatio * 55);
    const g = Math.floor(140 + energyRatio * 60);
    const b = Math.floor(20);
    ctx.fillStyle = `rgb(${r},${g},${b})`;

    const angle = Math.atan2(h.vy, h.vx);
    ctx.save();
    ctx.translate(h.x, h.y);
    ctx.rotate(angle);
    ctx.beginPath();
    ctx.moveTo(5, 0);
    ctx.lineTo(-3, -2.5);
    ctx.lineTo(-3, 2.5);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  // predators: brighter when well-fed, dimmer when hungry
  for (const p of predators) {
    const energyRatio = p.energy / p.maxEnergy;
    const r = Math.floor(150 + energyRatio * 105);
    const g = Math.floor(20 + energyRatio * 30);
    const b = Math.floor(15 + energyRatio * 20);
    ctx.fillStyle = `rgb(${r},${g},${b})`;

    const angle = Math.atan2(p.vy, p.vx);
    ctx.save();
    ctx.translate(p.x, p.y);
    ctx.rotate(angle);
    ctx.beginPath();
    ctx.moveTo(9, 0);
    ctx.lineTo(-5, -4.5);
    ctx.lineTo(-5, 4.5);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }
}

Energy-based coloring makes the ecosystem "readable" at a glance. Bright golden herbivores are well-fed (fat, about to reproduce). Dim brownish ones are starving (about to die). Same for predators -- bright red means recently fed, dark maroon means hungry and desperate. You can predict what's about to happen just by looking at the colors: a bright predator near a dim herbivore? That herbivore is too weak to flee effectively. A cluster of bright herbivores with no predators nearby? Baby boom incoming.

Putting it all together: the complete loop

const MAX_HERBIVORES = 200;
const MAX_PREDATORS = 30;

function fullSimulate() {
  frame++;

  // seasonal plant growth
  growPlantsWithSeasons();

  // update herbivores
  const newHerbs = [];
  for (let i = herbivores.length - 1; i >= 0; i--) {
    const h = herbivores[i];
    const result = updateHerbivore(h, plants, herbivores, predators);

    if (result === 'dead') {
      herbivores.splice(i, 1);
    } else if (result === 'reproduce') {
      const chance = reproductionChance(herbivores.length, MAX_HERBIVORES);
      if (Math.random() < chance / 0.01) {  // normalize against base rate
        newHerbs.push(new Herbivore(
          h.x + (Math.random() - 0.5) * 15,
          h.y + (Math.random() - 0.5) * 15
        ));
      }
    }
  }
  herbivores.push(...newHerbs);

  // update predators
  const newPreds = [];
  for (let i = predators.length - 1; i >= 0; i--) {
    const p = predators[i];
    const result = updatePredator(p, herbivores, predators);

    if (result === 'dead') {
      predators.splice(i, 1);
    } else if (result === 'reproduce') {
      const chance = reproductionChance(predators.length, MAX_PREDATORS);
      if (Math.random() < chance / 0.005) {
        newPreds.push(new Predator(
          p.x + (Math.random() - 0.5) * 25,
          p.y + (Math.random() - 0.5) * 25
        ));
      }
    }
  }
  predators.push(...newPreds);

  // record for graph
  recordPopulations();
}

function mainLoop() {
  fullSimulate();
  renderEcosystem();
  renderGraph();
  requestAnimationFrame(mainLoop);
}

mainLoop();

Run it. Watch it breathe. The first few hundred frames are chaotic as populations find their initial balance. Then the rhythms emerge -- herds form and dissolve, predators patrol territories, plant patches bloom and get grazed, the population graph draws its sinusoidal dance. You built a living world from the same primitives we've been using all arc long: grids, agents, forces, feedback.

Tuning: when things go wrong

If your ecosystem collapses (everything dies), check these parameters:

  • Herbivore starting energy too low: they die before finding food. Increase to 60-80
  • Plant growth too slow: herbivores starve no matter what. Increase growthRate
  • Predators too fast: herbivores can never escape. Reduce maxSpeed for predators or increase for herbivores
  • Kill radius too large: predators catch prey too easily. Reduce from 8 to 5-6 pixels
  • No refugia: prey population can be hunted to zero. Add 3-5 refugia areas
  • Reproduction threshold too low: populations grow explosively then crash hard. Increase energy requirements

If your ecosystem is boring (populations are stable but nothing interesting happens):

  • Add more predators initially: triggers bigger oscillations
  • Increase seasonal amplitude: more dramatic winters cause population bottlenecks
  • Reduce refugia size: less safety = more dramatic dynamics
  • Add occasional "disasters": periodically kill 30% of plants (drought, fire). Watch the cascade through the food chain

The ecosystem is a playground for parameter tweaking. Every number changes the dynamics. That's what makes it endlessly watchable -- you can spend hours tuning one parameter at a time and seeing how it ripples through all three trophic levels.

Connecting backwards and looking ahead

This mini-project uses almost everything from the arc:

  • Cellular automata (ep047-049): plant growth rules with neighbor-dependent spreading
  • Boids (ep050-051): herbivore flocking with separation, alignment, cohesion
  • Reaction-diffusion (ep052-053): the plant layer is essentialy a diffusing field
  • Agent-based modeling (ep056): autonomous agents with internal state, sensing, decision-making
  • Swarm intelligence (ep058): emergent efficiency from collective behavior (herds finding food patches)

It doesn't use L-systems (ep054-055) or waves (ep059) directly, but you could add them -- trees growing via L-system rules in the refugia, water waves on a river that herbivores need to cross. The ecosystem is extensible in any direction.

The emergent systems arc is done. Fourteen episodes, fourteen different ways to create complexity from simplicity, capped off by combining them into one living simulation. What we've built across this arc is a toolkit for modeling systems where parts interact to create wholes that are greater than the sum. Whether it's cellular grids, flying boids, chemical fields, or predator-prey dynamics -- the same principle applies: simple local rules, no global control, structure emerging from interaction.

What comes after emergence? We've been working in 2D the entire series. Flat canvases, flat grids, flat particle systems. But the world has depth. Objects occlude each other. Light bounces off surfaces. Geometry has volume. The next part of this series moves into three dimensions -- and that opens up an entirley new creative space.

't Komt erop neer...

  • The artificial ecosystem combines cellular automata (plant growth), boids (herbivore flocking), agent-based modeling (predator hunting behavior), and energy dynamics (metabolism, food chain) into one simulation running in a single Canvas
  • Three trophic levels: plants grow via CA rules with neighbor spreading and light competition; herbivores flock, graze, reproduce, and flee predators; predators hunt herbivores by chasing the nearest visible prey
  • Energy flows up the food chain: plants convert growth rate into biomass, herbivores convert consumed plants into energy, predators convert caught herbivores into energy. Every creature burns energy each frame and dies at zero
  • Population dynamics follow Lotka-Volterra predator-prey oscillation: prey peaks first, predators peak with a lag (more food = more predator survival), then prey crashes (overhunted), then predators crash (starvation), then the cycle repeats
  • Stabilizing mechanisms prevent extinction spirals: spatial refugia (safe havens where herbivores can hide and graze), density-dependent reproduction (harder to breed when population is high), and predator satiation (slower after a kill)
  • Seasonal variation (sinusoidal plant growth rate) adds a slow modulation on top of the fast predator-prey cycle, creating complex multi-frequency oscillations visible in the population graph
  • A real-time population graph (herbivore count, predator count, average plant density) lets you verify the system is producing the classic oscillating dynamics rather than collapsing toward extinction
  • This mini-project closes out the emergent systems arc. Fourteen episodes covering cellular automata, boids, reaction-diffusion, L-systems, agent crawlers, erosion, swarm intelligence, waves, and now their combination into a living ecosystem

Sallukes! Thanks for reading.

X

@femdev

Sort:  

Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You have been a buzzy bee and published a post every day of the week.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP