Fractal Forest

This sketch simulates a living patch of soil where nutrient-rich earth glows and drifts like breathing tissue, seeds fall from a starry night sky, and germinated seeds grow into branching plants using L-system grammar rules. Dead plants decay, return nutrients as glowing particles, and sometimes spawn spreading fungus, while a scrolling timeline chart tracks population and soil fertility over time.

🧪 Try This!

Experiment with the code by making these changes:

  1. Grow bigger, bushier plants — Raising the maximum L-system iterations lets plants branch out far more before they stop growing, producing much larger fractal trees.
  2. Turn the night sky into a dusky red — The background is painted pure black every frame - swapping the color here recolors the entire sky behind the soil and stars.
  3. Make fungus glow green instead of purple — Every fungus blob starts with this HSB color, so changing the hue recolors all fungus blooms across the whole soil bed.
Prefer the full editor? Open it there →

📖 About This Sketch

Fractal Forest renders a self-sustaining miniature ecosystem: a grid of glowing soil cells whose nutrient levels drift with Perlin noise and diffuse into neighboring cells, seeds that tumble down from a twinkling night sky, and plants that grow branch-by-branch using L-system (Lindenmayer system) string-rewriting rules. Fungus blooms out of decaying plant matter and slowly spreads across nearby soil, while nutrient particles fall and dissolve back into the ground, closing the loop between growth and decay. The whole scene is rendered in HSB color mode so nutrient richness, plant health, and light exposure can be mapped smoothly onto hue, saturation, and brightness.

The code is organized around one big state-update function, updateSimulationLogic(), which runs the noise drift, well regeneration, diffusion math, event timers (seed rain, drought, bloom), and every object's update() method, followed by a separate drawVisuals() function that paints everything each frame. Seeds, nutrient particles, fungus, and plants are each implemented as ES6 classes with their own update()/draw() methods, and plants use recursive string generation (generateLSystem) plus a shadow-casting pass to simulate competition for light. Studying this sketch is a great way to learn how to combine grid-based simulation, particle systems, procedural L-system graphics, and simple ecological feedback loops in a single p5.js project.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas in HSB color mode, then initializeSoil() builds a grid of nutrient cells seeded with Perlin noise and scatters a handful of nutrient wells, while initializeStars() scatters a starfield above the soil.
  2. Every frame, draw() calls updateSimulationLogic() (once, or five times in a row if fast-forward mode is on) and then drawVisuals() to paint the current state.
  3. Inside updateSimulationLogic(), the nutrient grid drifts with slowly-changing Perlin noise, regenerates near nutrient wells, and diffuses a small percentage of nutrient value between neighboring cells every frame, simulating a soft 'breathing' soil.
  4. Falling Seed objects check the nutrient level of the soil cell they land in; if it's high enough they germinate into a Plant, which grows over time by rewriting an L-system string (like 'F' becoming 'F[+F]F[-F]F') and drawing the result as a tree of line segments.
  5. A shadow-casting pass sorts plants by height and marks which columns are already shaded, so lower or newer branches that fall in shadow grow slower, dim in color, and can eventually die if they stay too shaded or too nutrient-starved - dead plants decay, release nutrient particles, and sometimes spawn spreading fungus.
  6. drawVisuals() layers the black night sky and twinkling stars, a shimmering light overlay, nutrient-colored soil cells, particles, fungus, plants, and seeds, then draws a rolling timeline chart of alive/dead plant counts and average fertility; mouse hovering near a plant also pops up an info box, and pressing F or G toggles fast-forward and the genealogy web overlay.

🎓 Concepts You'll Learn

2D grid-based simulationPerlin noise for organic driftL-systems (Lindenmayer systems) for procedural plantsHSB color mode for smooth gradientsES6 classes for game/simulation objectsDiffusion algorithms (cellular automata style)Particle systems with lifespansShadow-casting for light competition

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It's the right place to configure the canvas, color mode, and any one-time initialization like building the soil grid or starfield.

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100);
  noStroke();

  initializeSoil();
  initializeStars(); // create star positions for the night sky

  seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
}
Line-by-line explanation (6 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window.
colorMode(HSB, 360, 100, 100);
Switches color math to Hue/Saturation/Brightness so nutrient and light values can be mapped directly onto color.
noStroke();
Disables outlines by default; individual draw calls turn stroke back on when they need lines (like plant branches).
initializeSoil();
Builds the soil grid and nutrient wells before the first frame is drawn.
initializeStars(); // create star positions for the night sky
Pre-computes the positions and twinkle timing of every background star.
seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
Picks a random countdown until the very first seed falls, so seed timing feels organic rather than mechanical.

initializeSoil()

This function turns a handful of constants into the actual data structures the whole simulation depends on: the soilGrid array and the list of nutrient wells.

🔬 The 0.1 values control how zoomed-in the noise pattern is. What happens to the initial soil patches if you change both 0.1 values to 0.02 (bigger, smoother patches) or 0.3 (tighter, noisier patches)?

  soilGrid = Array(rows).fill(0).map((_, i) => Array(cols).fill(0).map((_, j) => {
    let initialNutrient = 50 + map(noise(j * 0.1, i * 0.1), 0, 1, -20, 20);
    return constrain(initialNutrient, 0, 100);
  }));
function initializeSoil() {
  rows = floor(soilHeight / cellSize);
  cols = floor(width / cellSize);

  soilGrid = Array(rows).fill(0).map((_, i) => Array(cols).fill(0).map((_, j) => {
    let initialNutrient = 50 + map(noise(j * 0.1, i * 0.1), 0, 1, -20, 20);
    return constrain(initialNutrient, 0, 100);
  }));

  nutrientWells = [];
  let numWells = floor(random(minWells, maxWells + 1));
  for (let k = 0; k < numWells; k++) {
    nutrientWells.push({
      x: random(width),
      radius: wellRadius
    });
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Build Soil Grid soilGrid = Array(rows).fill(0).map((_, i) => Array(cols).fill(0).map((_, j) => {...}));

Creates a 2D array of nutrient values, one per soil cell, seeded with Perlin noise so patches look natural rather than random static.

for-loop Scatter Nutrient Wells for (let k = 0; k < numWells; k++) { nutrientWells.push({ x: random(width), radius: wellRadius }); }

Places a random number of nutrient wells (fertile hotspots) at random x positions across the width of the soil.

rows = floor(soilHeight / cellSize);
Calculates how many grid rows fit in the soil band, based on cell size.
cols = floor(width / cellSize);
Calculates how many grid columns fit across the canvas width.
let initialNutrient = 50 + map(noise(j * 0.1, i * 0.1), 0, 1, -20, 20);
Starts every cell around 50% nutrient, then nudges it up or down using 2D Perlin noise so nearby cells have similar starting values (organic patches instead of random noise).
return constrain(initialNutrient, 0, 100);
Clamps the nutrient value so it always stays within the valid 0-100 range.
let numWells = floor(random(minWells, maxWells + 1));
Randomly decides how many nutrient wells to create this run, between minWells and maxWells.

initializeStars()

Precomputing star data once (instead of generating random positions every frame) keeps the starfield stable and avoids flickering positions - a common trick for background decoration in generative art.

function initializeStars() {
  stars = [];
  const skyHeight = height - soilHeight; // region from y=0 up to top of soil

  for (let i = 0; i < numStars; i++) {
    stars.push({
      x: random(width),
      y: random(skyHeight),
      size: random(1, 3),
      baseBrightness: random(60, 100),
      twinkleOffset: random(TWO_PI),
      twinkleSpeed: random(0.005, 0.02)
    });
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Generate Star Objects for (let i = 0; i < numStars; i++) { stars.push({...}); }

Creates numStars star objects, each with its own position, size, and twinkle timing so they don't all blink in sync.

const skyHeight = height - soilHeight; // region from y=0 up to top of soil
Stars are only placed in the sky region above the soil band, never inside the ground.
twinkleOffset: random(TWO_PI),
Gives each star a different starting phase in its sine-wave twinkle, so stars flicker independently instead of all pulsing together.
twinkleSpeed: random(0.005, 0.02)
Randomizes how fast each star twinkles for extra visual variety.

updateSimulationLogic()

This is the simulation's engine room: every rule about how nutrients move, how light and shadow work, and how living things age all live inside this one function, called once (or several times in fast-forward) before every render.

🔬 seedRainInterval decides how often a burst of seeds falls. What happens if you make seedRainInterval much smaller, like 200, so seed rain happens far more often?

  if (frameCount % seedRainInterval === 0 && !droughtActive) { // No seed rain during drought
    seedRainActive = true;
    seedRainTimer = seedRainCount;
  }
function updateSimulationLogic() {
  const soilStartY = height - soilHeight;

  // === Event Management ===
  // Seed Rain
  if (frameCount % seedRainInterval === 0 && !droughtActive) { // No seed rain during drought
    seedRainActive = true;
    seedRainTimer = seedRainCount;
  }
  if (seedRainActive && seedRainTimer > 0) {
    seeds.push(new Seed());
    seedRainTimer--;
    if (seedRainTimer <= 0) seedRainActive = false;
  }

  // Drought
  if (frameCount % (seedRainInterval + 500) === 0 && !droughtActive) {
    droughtActive = true;
    droughtTimer = droughtDuration;
  }
  if (droughtActive) {
    droughtTimer--;
    if (droughtTimer <= 0) droughtActive = false;
  }

  // Bloom
  if (frameCount % bloomInterval === 0 && !droughtActive) {
    bloomActive = true;
    bloomTimer = bloomDuration;
    for (let plant of plants) {
      if (plant.isAlive) {
        plant.flowering = true;
        plant.flowerTimer = bloomDuration;
      }
    }
  }
  if (bloomActive) {
    bloomTimer--;
    if (bloomTimer <= 0) bloomActive = false;
  }

  // === Update Nutrients ===

  // 1. Perlin Noise Drift
  noiseOffset += noiseDriftRate;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let noiseValue = noise(j * noiseScale, i * noiseScale + noiseOffset);
      soilGrid[i][j] += map(noiseValue, 0, 1, -noiseNutrientInfluence, noiseNutrientInfluence);
      soilGrid[i][j] = constrain(soilGrid[i][j], 0, 100);
    }
  }

  // 2. Nutrient Well Regeneration (disabled during drought)
  if (!droughtActive) {
    for (let well of nutrientWells) {
      for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
          let cellX = j * cellSize + cellSize / 2;
          let cellY = i * cellSize + cellSize / 2 + soilStartY;

          let d = dist(well.x, soilStartY + soilHeight / 2, cellX, cellY);

          if (d < well.radius) {
            let regenAmount = wellRegenRate * (1 - d / well.radius);
            soilGrid[i][j] = constrain(soilGrid[i][j] + regenAmount, 0, 100);
          }
        }
      }
    }
  }

  // 3. Nutrient Diffusion
  let nextSoilGrid = Array(rows).fill(0).map(() => Array(cols).fill(0));

  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let currentNutrient = soilGrid[i][j];
      let neighborsNutrientSum = 0;
      let numNeighbors = 0;

      if (j > 0) {
        neighborsNutrientSum += soilGrid[i][j - 1];
        numNeighbors++;
      }
      if (j < cols - 1) {
        neighborsNutrientSum += soilGrid[i][j + 1];
        numNeighbors++;
      }
      if (i > 0) {
        neighborsNutrientSum += soilGrid[i - 1][j];
        numNeighbors++;
      }
      if (i < rows - 1) {
        neighborsNutrientSum += soilGrid[i + 1][j];
        numNeighbors++;
      }

      let avgNeighborNutrients = numNeighbors > 0 ? neighborsNutrientSum / numNeighbors : currentNutrient;

      nextSoilGrid[i][j] = constrain(
        currentNutrient * (1 - diffusionRate) + avgNeighborNutrients * diffusionRate,
        0, 100
      );
    }
  }
  soilGrid = nextSoilGrid;

  // 4. Drought Effect: slowly deplete nutrients while drought is active
  if (droughtActive) {
    const droughtRate = 0.005; // ~0.5% loss per frame
    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < cols; j++) {
        soilGrid[i][j] = max(0, soilGrid[i][j] * (1 - droughtRate));
      }
    }
  }

  // === Light Layer Update ===
  lightNoiseOffset += lightDriftRate;

  // === Shadow Calculation and Light Exposure for Plants ===
  let shadowMap = Array(cols).fill(0);

  let sortedPlants = [...plants].sort((a, b) => {
    if (!a.isAlive && !b.isAlive) return 0;
    if (!a.isAlive) return 1;
    if (!b.isAlive) return -1;

    let aHighestY = height;
    if (a.branches.length > 0) aHighestY = min(aHighestY, min(a.branches.map(b => min(b.startY, b.endY))));
    let bHighestY = height;
    if (b.branches.length > 0) bHighestY = min(bHighestY, min(b.branches.map(b => min(b.startY, b.endY))));
    return aHighestY - bHighestY;
  });

  for (let plant of sortedPlants) {
    if (!plant.isAlive) continue;

    let plantTotalLightExposure = 0;
    let plantBranchCount = 0;

    for (let branch of plant.branches) {
      let branchX1 = floor(min(branch.startX, branch.endX) / cellSize);
      let branchX2 = floor(max(branch.startX, branch.endX) / cellSize);
      branchX1 = constrain(branchX1, 0, cols - 1);
      branchX2 = constrain(branchX2, 0, cols - 1);

      let branchYBlock = floor(min(branch.startY, branch.endY));

      let unshadowedWidth = 0;
      let totalWidth = branchX2 - branchX1 + 1;

      for (let x = branchX1; x <= branchX2; x++) {
        if (branchYBlock < shadowMap[x]) {
          unshadowedWidth++;
        }
      }

      branch.lightExposure = unshadowedWidth / totalWidth;
      plantTotalLightExposure += branch.lightExposure;
      plantBranchCount++;

      for (let x = branchX1; x <= branchX2; x++) {
        shadowMap[x] = max(shadowMap[x], branchYBlock);
      }
    }

    plant.lightExposure = plantBranchCount > 0 ? plantTotalLightExposure / plantBranchCount : 1.0;
  }

  // === Seed Spawning ===
  if (!seedRainActive) {
    seedSpawnTimer--;
    if (seedSpawnTimer <= 0) {
      seeds.push(new Seed());
      seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
    }
  }

  // === Update Seeds ===
  for (let i = seeds.length - 1; i >= 0; i--) {
    seeds[i].update();
    if (seeds[i].state === 'germinating' || seeds[i].y > height + seedSize) {
      seeds.splice(i, 1);
    }
  }

  // === Update Plants ===
  let livingPlantsCount = 0;
  for (let i = plants.length - 1; i >= 0; i--) {
    plants[i].update();
    if (plants[i].isAlive) {
      livingPlantsCount++;
    } else if (plants[i].isDead()) {
      plantIDMap.delete(plants[i].id);
      plants.splice(i, 1);
    }
  }
  totalPlantsAlive = livingPlantsCount;

  // === Update Fungus ===
  for (let i = fungus.length - 1; i >= 0; i--) {
    fungus[i].update();
    if (fungus[i].isDead()) {
      fungus.splice(i, 1);
    }
  }

  // === Update Nutrient Particles ===
  for (let i = nutrientParticles.length - 1; i >= 0; i--) {
    nutrientParticles[i].update();
    if (nutrientParticles[i].isDissolved()) {
      nutrientParticles.splice(i, 1);
    }
  }

  // === Update Timeline Data ===
  aliveHistory.push(totalPlantsAlive);
  deadHistory.push(totalPlantsDead);
  let currentFertilitySum = 0;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      currentFertilitySum += soilGrid[i][j];
    }
  }
  fertilityHistory.push(currentFertilitySum / (rows * cols));

  if (aliveHistory.length > historyLength) aliveHistory.shift();
  if (deadHistory.length > historyLength) deadHistory.shift();
  if (fertilityHistory.length > historyLength) fertilityHistory.shift();
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Event Timers if (frameCount % seedRainInterval === 0 && !droughtActive) { seedRainActive = true; ... }

Uses frameCount's remainder (the modulo operator) to trigger recurring seed rain, drought, and bloom events at fixed intervals.

for-loop Perlin Noise Drift for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { ... soilGrid[i][j] += map(noiseValue, ...); } }

Nudges every soil cell's nutrient value using slowly-shifting Perlin noise, creating the organic 'breathing' texture.

for-loop Nutrient Well Regeneration for (let well of nutrientWells) { for (let i ...) { for (let j ...) { if (d < well.radius) { ... } } } }

Adds nutrient near each well's center based on distance, refilling nearby soil unless a drought is active.

for-loop Nutrient Diffusion nextSoilGrid[i][j] = constrain(currentNutrient * (1 - diffusionRate) + avgNeighborNutrients * diffusionRate, 0, 100);

Blends each cell's nutrient value toward the average of its neighbors, simulating nutrients spreading through soil like heat diffusion.

for-loop Shadow / Light Exposure Calculation for (let plant of sortedPlants) { ... branch.lightExposure = unshadowedWidth / totalWidth; ... }

Sorts plants top-to-bottom and marks a per-column shadowMap so lower branches know how much light they're blocked from, driving growth speed and death.

for-loop Update & Prune Object Arrays for (let i = plants.length - 1; i >= 0; i--) { plants[i].update(); if (...) { plants.splice(i, 1); } }

Loops backward through seeds, plants, fungus, and particles so items can be safely removed from the array mid-loop without skipping elements.

if (frameCount % seedRainInterval === 0 && !droughtActive) {
Every seedRainInterval frames (and only if it isn't drought), this kicks off a burst of extra seeds.
noiseOffset += noiseDriftRate;
Very slowly advances the noise 'z' coordinate so the nutrient texture drifts like slow breathing instead of staying static.
let noiseValue = noise(j * noiseScale, i * noiseScale + noiseOffset);
Samples 2D Perlin noise at each cell's position, offset by the drifting noiseOffset, to get a smoothly varying value between 0 and 1.
let d = dist(well.x, soilStartY + soilHeight / 2, cellX, cellY);
Measures how far a soil cell is from a nutrient well's center to decide how much bonus nutrient it should receive.
let avgNeighborNutrients = numNeighbors > 0 ? neighborsNutrientSum / numNeighbors : currentNutrient;
Averages the nutrient values of up-to-4 neighboring cells (fewer at grid edges), which is the core of the diffusion effect.
soilGrid = nextSoilGrid;
Swaps in the newly-calculated grid all at once, so diffusion uses last frame's values consistently rather than partially-updated ones.
let shadowMap = Array(cols).fill(0);
Creates a fresh array tracking, for each column of the grid, how high the tallest shading branch currently reaches.
branch.lightExposure = unshadowedWidth / totalWidth;
Calculates what fraction of a branch's width is NOT already shaded by another plant, giving a 0-1 light score.
for (let i = plants.length - 1; i >= 0; i--) {
Iterates backward through the plants array so that removing dead plants with splice() doesn't cause the loop to skip the next item.
fertilityHistory.push(currentFertilitySum / (rows * cols));
Records the average nutrient level across the whole grid this frame, feeding the amber line in the timeline chart.

drawStars()

drawStars() shows a classic technique: pre-generate random data once in setup, then use frameCount and sin() inside draw to animate each item independently without storing any extra 'current brightness' state.

🔬 The 0.4 sets how dim a star gets at its darkest. What happens if you change 0.4 to 0.9 (barely dims) or 0.05 (almost disappears between twinkles)?

    const phase = frameCount * s.twinkleSpeed + s.twinkleOffset;
    const twinkle = (sin(phase) + 1) / 2; // 0..1
    const b = lerp(s.baseBrightness * 0.4, s.baseBrightness, twinkle);
function drawStars() {
  const skyHeight = height - soilHeight;

  push();
  noStroke();
  for (let s of stars) {
    const phase = frameCount * s.twinkleSpeed + s.twinkleOffset;
    const twinkle = (sin(phase) + 1) / 2; // 0..1
    const b = lerp(s.baseBrightness * 0.4, s.baseBrightness, twinkle);
    fill(0, 0, b); // white/gray in HSB
    circle(s.x, s.y, s.size);
  }
  pop();
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Twinkle & Draw Each Star for (let s of stars) { ... fill(0, 0, b); circle(s.x, s.y, s.size); }

Uses a sine wave per star (each with its own phase offset) to pulse brightness up and down, then draws the star as a small circle.

const phase = frameCount * s.twinkleSpeed + s.twinkleOffset;
Combines the current frame number with the star's own speed and starting offset so every star twinkles on a different rhythm.
const twinkle = (sin(phase) + 1) / 2; // 0..1
sin() naturally oscillates between -1 and 1; this remaps it to a friendlier 0-1 range for brightness blending.
const b = lerp(s.baseBrightness * 0.4, s.baseBrightness, twinkle);
Interpolates between a dim version and the star's full brightness based on the twinkle value, making it pulse smoothly.
fill(0, 0, b); // white/gray in HSB
In HSB mode, hue and saturation of 0 with varying brightness produces shades of white/gray - a simple way to draw stars.

drawVisuals()

drawVisuals() is a pure rendering pass - it never changes simulation state, it only reads soilGrid, plants, fungus, particles, and history arrays and paints them. Separating 'update logic' from 'draw visuals' like this is a common and very useful p5.js pattern.

🔬 lightBrightnessMax caps how bright the shimmering light bands get. What happens if you raise lightBrightnessMax from 10 to 40?

  for (let x = 0; x < width; x += cellSize) {
    let brightness = map(
      noise(x * lightNoiseScale + lightNoiseOffset),
      0, 1,
      lightBrightnessMin, lightBrightnessMax
    );
    fill(0, 0, 100, brightness); // White with varying transparency
    rect(x, soilStartY, cellSize, soilHeight); // only over the soil area
  }
function drawVisuals() {
  const soilStartY = height - soilHeight;

  // === Background Sky ===
  // Pure black background for a night sky
  // https://p5js.org/reference/#/p5/background
  background(0, 0, 0);

  // Draw starfield in the sky region
  drawStars();

  // === Light Layer Visualization (confined to soil) ===
  // Subtle shimmering light within the soil, without affecting the black sky.
  push();
  noStroke();
  for (let x = 0; x < width; x += cellSize) {
    let brightness = map(
      noise(x * lightNoiseScale + lightNoiseOffset),
      0, 1,
      lightBrightnessMin, lightBrightnessMax
    );
    fill(0, 0, 100, brightness); // White with varying transparency
    rect(x, soilStartY, cellSize, soilHeight); // only over the soil area
  }
  pop();

  // === Draw Soil Cells ===
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let nutrientValue = soilGrid[i][j];

      let currentHue = map(nutrientValue, 0, 100, depletedHue, richHue);
      let currentSat = map(nutrientValue, 0, 100, depletedSat, richSat);
      let currentBright = map(nutrientValue, 0, 100, depletedBright, richBright);

      fill(currentHue, currentSat, currentBright);
      rect(j * cellSize, i * cellSize + soilStartY, cellSize, cellSize);
    }
  }

  // === Draw Nutrient Particles ===
  noStroke();
  for (let particle of nutrientParticles) {
    particle.draw();
  }

  // === Draw Fungus ===
  noStroke();
  for (let f of fungus) {
    f.draw();
  }

  // === Draw Genealogy Web (if toggled) ===
  if (showGenealogyWeb) {
    push();
    stroke(0, 0, 50, 15);
    strokeWeight(0.5);
    for (let plant of plants) {
      if (plant.offspringPlantIDs.length > 0) {
        for (let offspringID of plant.offspringPlantIDs) {
          let offspring = plantIDMap.get(offspringID);
          if (offspring) {
            line(plant.x, plant.y, offspring.x, offspring.y);
          }
        }
      }
    }
    pop();
  }

  // === Draw Plants ===
  for (let plant of plants) {
    plant.draw();
  }

  // === Draw Seeds ===
  noStroke();
  for (let seed of seeds) {
    seed.draw();
  }

  // === Mouse Hover Plant Info ===
  let hoveredPlant = null;
  for (let i = plants.length - 1; i >= 0; i--) {
    let plant = plants[i];
    if (plant.isAlive || plant.decayTimer > 0) {
      if (dist(mouseX, mouseY, plant.x, plant.y) < plantBaseLength * 2) {
        hoveredPlant = plant;
        break;
      }
    }
  }

  if (hoveredPlant) {
    push();
    translate(mouseX + 10, mouseY + 10);
    fill(0, 0, 100, 80);
    noStroke();
    rect(0, 0, 150, 80);

    fill(0);
    textSize(10);
    textFont('Arial');
    text(`Age: ${frameCount - hoveredPlant.birthFrame} frames`, 5, 15);
    text(`Seed Type: ${hoveredPlant.seedShape}`, 5, 30);
    text(`Light: ${nf(hoveredPlant.lightExposure, 1, 2)}`, 5, 45);
    text(`Alive: ${hoveredPlant.isAlive}`, 5, 60);

    if (hoveredPlant.offspringPlantIDs.length > 0) {
      push();
      stroke(0, 0, 50, 50);
      strokeWeight(1);
      for (let offspringID of hoveredPlant.offspringPlantIDs) {
        let offspring = plantIDMap.get(offspringID);
        if (offspring) {
          line(hoveredPlant.x, hoveredPlant.y, offspring.x, offspring.y);
        }
      }
      pop();
    }
    pop();
  }

  // === Timeline Bar ===
  push();
  translate(0, height - timelineHeight);
  fill(0, 0, 0, 50);
  noStroke();
  rect(0, 0, width, timelineHeight);

  const maxAlive = max(aliveHistory);
  const maxDead = max(deadHistory);
  const maxFertility = max(fertilityHistory);
  const maxCombined = max(maxAlive, maxDead, maxFertility);

  // Alive History (Green)
  noFill();
  stroke(120, 80, 70);
  strokeWeight(2);
  beginShape();
  vertex(0, timelineHeight);
  for (let i = 0; i < aliveHistory.length; i++) {
    let x = map(i, 0, historyLength - 1, 0, width);
    let y = map(aliveHistory[i], 0, maxCombined, timelineHeight, 0);
    vertex(x, y);
  }
  vertex(width, timelineHeight);
  endShape();

  // Dead History (Gray)
  stroke(0, 0, 50);
  beginShape();
  vertex(0, timelineHeight);
  for (let i = 0; i < deadHistory.length; i++) {
    let x = map(i, 0, historyLength - 1, 0, width);
    let y = map(deadHistory[i], 0, maxCombined, timelineHeight, 0);
    vertex(x, y);
  }
  vertex(width, timelineHeight);
  endShape();

  // Fertility History (Amber)
  stroke(richHue, richSat, richBright);
  beginShape();
  vertex(0, timelineHeight);
  for (let i = 0; i < fertilityHistory.length; i++) {
    let x = map(i, 0, historyLength - 1, 0, width);
    let y = map(fertilityHistory[i], 0, 100, timelineHeight, 0);
    vertex(x, y);
  }
  vertex(width, timelineHeight);
  endShape();

  pop();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

for-loop Light Shimmer Overlay for (let x = 0; x < width; x += cellSize) { ... fill(0, 0, 100, brightness); rect(x, soilStartY, cellSize, soilHeight); }

Draws thin translucent white vertical strips over the soil, with brightness driven by drifting noise, to simulate shifting light bands.

for-loop Draw Soil Cells for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { ... fill(currentHue, currentSat, currentBright); rect(...); } }

Maps each cell's nutrient value (0-100) onto a hue/saturation/brightness gradient from depleted brown to rich amber, then draws it as a rectangle.

for-loop Find Hovered Plant for (let i = plants.length - 1; i >= 0; i--) { ... if (dist(mouseX, mouseY, plant.x, plant.y) < plantBaseLength * 2) { hoveredPlant = plant; break; } }

Checks the mouse position against every plant's base position to find the closest one under the cursor, so a tooltip can be shown.

for-loop Draw Timeline Curves beginShape(); vertex(0, timelineHeight); for (let i = 0; i < aliveHistory.length; i++) { ... vertex(x, y); } vertex(width, timelineHeight); endShape();

Converts the rolling history arrays into filled/stroked line graphs for alive plants, dead plants, and soil fertility.

background(0, 0, 0);
Paints the whole canvas pure black every frame, acting as the night sky and clearing the previous frame.
drawStars();
Delegates to a helper function to draw the twinkling starfield on top of the black sky.
let brightness = map(noise(x * lightNoiseScale + lightNoiseOffset), 0, 1, lightBrightnessMin, lightBrightnessMax);
Samples 1D Perlin noise across the x-axis to decide how bright each vertical light strip should be, creating drifting light bands.
let currentHue = map(nutrientValue, 0, 100, depletedHue, richHue);
Nutrient-poor cells get the 'depleted' hue (brownish) and nutrient-rich cells get the 'rich' hue (amber), blending smoothly in between.
if (dist(mouseX, mouseY, plant.x, plant.y) < plantBaseLength * 2) {
Uses distance from the mouse to the plant's root to detect hover, so info only appears when the cursor is close to a plant's base.
const maxCombined = max(maxAlive, maxDead, maxFertility);
Finds a single shared scale so the alive and dead plant-count lines in the timeline chart use the same vertical axis.
let y = map(aliveHistory[i], 0, maxCombined, timelineHeight, 0);
Converts a plant count into a pixel y-position, flipped so higher counts draw higher up (smaller y) in the chart.

draw()

draw() is p5.js's main animation loop, called ~60 times per second automatically. Here it's kept intentionally tiny, delegating all the real work to updateSimulationLogic() and drawVisuals() - a clean way to organize a complex sketch.

function draw() {
  let simulationSteps = fastForward ? 5 : 1;

  for (let i = 0; i < simulationSteps; i++) {
    updateSimulationLogic();
  }

  drawVisuals();
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Fast-Forward Stepping for (let i = 0; i < simulationSteps; i++) { updateSimulationLogic(); }

Runs the simulation update multiple times per rendered frame when fast-forward mode is active, speeding up the ecosystem without speeding up the frame rate.

let simulationSteps = fastForward ? 5 : 1;
Uses a ternary to decide whether to simulate 1 or 5 steps this frame, based on the fastForward toggle set by pressing 'F'.
updateSimulationLogic();
Advances all the nutrient math, growth, and decay by one simulated tick.
drawVisuals();
Renders the current state to the screen exactly once, regardless of how many simulation steps ran this frame.

windowResized()

windowResized() is a built-in p5.js callback fired automatically whenever the browser window changes size. Resetting the whole simulation here avoids leftover objects pointing at grid cells that no longer exist.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);

  initializeSoil();
  initializeStars(); // regenerate star positions for new size

  seeds = [];
  plants = [];
  fungus = [];
  plantIDMap.clear();
  totalPlantsAlive = 0;
  totalPlantsDead = 0;
  aliveHistory = [];
  deadHistory = [];
  fertilityHistory = [];
  seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
}
Line-by-line explanation (4 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js function that adjusts canvas dimensions when the browser window changes size.
initializeSoil();
Rebuilds the soil grid and wells to fit the new canvas dimensions, since rows/cols depend on width.
seeds = []; plants = []; fungus = [];
Clears all living objects, since their positions and soil-cell references would no longer make sense on the resized grid.
plantIDMap.clear();
Empties the Map that looks up plants by ID, keeping it in sync with the now-empty plants array.

keyTyped()

keyTyped() is a built-in p5.js event function that fires once per keystroke, ideal for toggles like this rather than continuous checks in draw().

function keyTyped() {
  if (key === 'f' || key === 'F') {
    fastForward = !fastForward;
  }
  if (key === 'g' || key === 'G') {
    showGenealogyWeb = !showGenealogyWeb;
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional Toggle Fast-Forward if (key === 'f' || key === 'F') { fastForward = !fastForward; }

Flips the fastForward boolean whenever the F key is pressed, speeding up or slowing down the simulation.

conditional Toggle Genealogy Web if (key === 'g' || key === 'G') { showGenealogyWeb = !showGenealogyWeb; }

Flips whether family-tree lines between parent and offspring plants are drawn.

if (key === 'f' || key === 'F') {
Checks both lowercase and uppercase 'F' so the toggle works whether or not Caps Lock/Shift is on.
fastForward = !fastForward;
The ! (not) operator flips true to false and false to true, giving a simple on/off toggle.

polygon()

This is a small reusable helper used only by the Seed class's draw() method - a good example of factoring out repeated geometry math (drawing any regular polygon) into its own function.

🔬 This function is reused for both pentagon (5 points) and hexagon (6 points) seeds by changing npoints. What kind of shape would you get by calling polygon() with npoints set to 3, or 20?

  for (let a = 0; a < TWO_PI; a += angle) {
    let sx = x + cos(a) * radius;
    let sy = y + sin(a) * radius;
    vertex(sx, sy);
  }
function polygon(x, y, radius, npoints) {
  let angle = TWO_PI / npoints;
  beginShape();
  for (let a = 0; a < TWO_PI; a += angle) {
    let sx = x + cos(a) * radius;
    let sy = y + sin(a) * radius;
    vertex(sx, sy);
  }
  endShape(CLOSE);
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

for-loop Place Vertices Around a Circle for (let a = 0; a < TWO_PI; a += angle) { let sx = x + cos(a) * radius; let sy = y + sin(a) * radius; vertex(sx, sy); }

Walks around a full circle in equal angular steps, placing one vertex per step to form a regular polygon (pentagon, hexagon, etc.).

let angle = TWO_PI / npoints;
Divides a full circle (TWO_PI radians) evenly by the number of points requested, giving the angular step between vertices.
let sx = x + cos(a) * radius;
Uses cosine of the current angle to find the x-offset of this vertex from the shape's center.
let sy = y + sin(a) * radius;
Uses sine of the current angle to find the y-offset of this vertex from the shape's center.
endShape(CLOSE);
Closes the shape by connecting the last vertex back to the first, completing the polygon outline.

Seed (class)

Seed demonstrates a simple state machine ('falling' -> 'dormant'/'germinating') - a pattern you'll reuse constantly in games and simulations whenever an object behaves differently depending on its current mode.

🔬 What happens to how quickly the forest fills in if you make dormantRecheckInterval much shorter, so dormant seeds check the soil far more often?

        let nutrientValue = soilGrid[row][col];
        if (nutrientValue >= germinationNutrientThreshold) {
          this.state = 'germinating';
          this.germinate();
        } else {
          this.state = 'dormant';
          this.dormantCheckTimer = dormantRecheckInterval;
        }
class Seed {
  constructor(parentPlantID = null) {
    this.x = random(width);
    this.y = 0;
    this.vx = 0;
    this.vy = 0;
    this.size = seedSize;
    this.shape = random(['triangle', 'square', 'pentagon', 'hexagon']);
    this.state = 'falling';
    this.soilCell = null;
    this.dormantCheckTimer = 0;
    this.parentPlantID = parentPlantID;
  }

  update() {
    if (this.state === 'falling') {
      this.vy += seedGravity;

      this.vx += random(-seedWindForce, seedWindForce);
      this.vx = constrain(this.vx, -seedWindForce * 5, seedWindForce * 5);

      this.x += this.vx;
      this.y += this.vy;

      this.x = constrain(this.x, this.size / 2, width - this.size / 2);

      const soilStartY = height - soilHeight;
      if (this.y >= soilStartY - this.size / 2) {
        let col = floor(this.x / cellSize);
        let row = floor((this.y - soilStartY) / cellSize);

        row = constrain(row, 0, rows - 1);
        col = constrain(col, 0, cols - 1);

        this.soilCell = [row, col];

        let nutrientValue = soilGrid[row][col];
        if (nutrientValue >= germinationNutrientThreshold) {
          this.state = 'germinating';
          this.germinate();
        } else {
          this.state = 'dormant';
          this.dormantCheckTimer = dormantRecheckInterval;
        }
        this.vx = 0;
        this.vy = 0;
        this.y = soilStartY + row * cellSize + cellSize / 2;
      }
    } else if (this.state === 'dormant') {
      this.dormantCheckTimer--;
      if (this.dormantCheckTimer <= 0) {
        if (this.soilCell) {
          let [row, col] = this.soilCell;
          let nutrientValue = soilGrid[row][col];
          if (nutrientValue >= germinationNutrientThreshold) {
            this.state = 'germinating';
            this.germinate();
          }
        }
        this.dormantCheckTimer = dormantRecheckInterval;
      }
    }
  }

  draw() {
    push();
    translate(this.x, this.y);
    rotate(frameCount * 0.01);

    if (this.state === 'falling') {
      fill(depletedHue + 5, depletedSat + 30, 40);
    } else if (this.state === 'dormant') {
      fill(0, 0, 30);
    } else {
      pop();
      return;
    }

    switch (this.shape) {
      case 'triangle':
        triangle(-this.size / 2, this.size / 2, 0, -this.size / 2, this.size / 2, this.size / 2);
        break;
      case 'square':
        rect(-this.size / 2, -this.size / 2, this.size, this.size);
        break;
      case 'pentagon':
        polygon(0, 0, this.size / 2, 5);
        break;
      case 'hexagon':
        polygon(0, 0, this.size / 2, 6);
        break;
    }
    pop();
  }

  germinate() {
    if (this.soilCell) {
      let [row, col] = this.soilCell;
      let nutrientValue = soilGrid[row][col];
      if (nutrientValue >= germinationNutrientThreshold) {
        soilGrid[row][col] = constrain(nutrientValue - nutrientConsumptionOnGermination, 0, 100);
        plants.push(new Plant(this.x, this.y, this.soilCell, this.shape, this.parentPlantID));
      }
    }
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Falling State Physics if (this.state === 'falling') { this.vy += seedGravity; ... }

Applies gravity and random wind force while the seed is airborne, and checks whether it has reached the soil surface.

conditional Dormant Recheck } else if (this.state === 'dormant') { this.dormantCheckTimer--; if (this.dormantCheckTimer <= 0) { ... } }

Periodically rechecks nutrient levels for a dormant seed that landed on poor soil, allowing it to germinate later if conditions improve.

switch-case Draw Shape by Type switch (this.shape) { case 'triangle': ... case 'hexagon': polygon(0, 0, this.size / 2, 6); break; }

Chooses which p5.js drawing call to use based on the seed's randomly-assigned shape.

this.shape = random(['triangle', 'square', 'pentagon', 'hexagon']);
Randomly picks one of four shapes for this seed; the shape later determines the plant's L-system rules and color.
this.vy += seedGravity;
Accelerates the seed downward each frame, simulating gravity.
this.vx += random(-seedWindForce, seedWindForce);
Adds a small random horizontal nudge each frame, simulating wind gusts.
if (this.y >= soilStartY - this.size / 2) {
Checks whether the falling seed has reached the top of the soil band.
if (nutrientValue >= germinationNutrientThreshold) {
Only seeds landing on sufficiently fertile soil germinate immediately; otherwise they go dormant and keep checking.
soilGrid[row][col] = constrain(nutrientValue - nutrientConsumptionOnGermination, 0, 100);
Germinating consumes a chunk of the local soil's nutrients, giving the new plant a real cost.
plants.push(new Plant(this.x, this.y, this.soilCell, this.shape, this.parentPlantID));
Creates a brand new Plant object at the seed's location, passing along its shape (which determines its growth rules and color).

NutrientParticle (class)

NutrientParticle closes the sketch's nutrient cycle: plants consume nutrients to grow, and when they die, returnNutrients() spawns these particles, which fall and feed the soil again via dissolve().

class NutrientParticle {
  constructor(x, y, nutrientValue) {
    this.x = x + random(-cellSize / 2, cellSize / 2);
    this.y = y;
    this.vy = 0;
    this.nutrientValue = nutrientValue;
    this.size = random(particleSizeMin, particleSizeMax);
    this.state = 'falling';
    this.dissolveTimer = particleLifeSpan;
  }

  update() {
    if (this.state === 'falling') {
      this.vy += particleGravity;
      this.y += this.vy;

      const soilStartY = height - soilHeight;
      if (this.y >= soilStartY) {
        this.y = soilStartY;
        this.vy = 0;
        this.state = 'dissolving';
        this.dissolve();
      }
    } else if (this.state === 'dissolving') {
      this.dissolveTimer--;
      if (this.dissolveTimer <= 0) {
        this.state = 'dissolved';
      }
    }
  }

  draw() {
    if (this.state === 'falling') {
      fill(depletedHue, depletedSat + 10, 20);
      circle(this.x, this.y, this.size);
    } else if (this.state === 'dissolving') {
      let alpha = map(this.dissolveTimer, 0, particleLifeSpan, 0, 100);
      fill(depletedHue, depletedSat + 10, 20, alpha);
      circle(this.x, this.y, this.size * map(this.dissolveTimer, 0, particleLifeSpan, 1.5, 1));
    }
  }

  dissolve() {
    let col = floor(this.x / cellSize);
    let row = floor((this.y - (height - soilHeight)) / cellSize);

    row = constrain(row, 0, rows - 1);
    col = constrain(col, 0, cols - 1);

    soilGrid[row][col] = constrain(soilGrid[row][col] + this.nutrientValue, 0, 100);
  }

  isDissolved() {
    return this.state === 'dissolved';
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Falling Physics if (this.state === 'falling') { this.vy += particleGravity; this.y += this.vy; ... }

Accelerates the particle downward until it reaches the soil surface, then switches it into the dissolving state.

conditional Dissolve Countdown } else if (this.state === 'dissolving') { this.dissolveTimer--; if (this.dissolveTimer <= 0) { this.state = 'dissolved'; } }

Counts down a fixed lifespan after the particle has released its nutrients, fading it out visually before removal.

this.x = x + random(-cellSize / 2, cellSize / 2);
Spreads particles out slightly around their spawn point so they don't all stack perfectly on top of each other.
this.vy += particleGravity;
Simple downward acceleration, just like the seeds.
soilGrid[row][col] = constrain(soilGrid[row][col] + this.nutrientValue, 0, 100);
This is the key ecological loop: when a particle dissolves, it adds its stored nutrient value back into the soil grid, returning matter from decayed plants.
let alpha = map(this.dissolveTimer, 0, particleLifeSpan, 0, 100);
Fades the particle's opacity down to zero over its lifespan as it dissolves into the ground.

Fungus (class)

Fungus shows how a decomposer can be modeled with just three behaviors: grow slowly, speed up decay of nearby dead matter, and occasionally spread to empty neighboring cells - simple rules producing convincing ecological behavior.

🔬 The 0.2 chance controls how aggressively fungus colonizes empty soil. What happens if you raise it to 0.8, making fungus spread almost every attempt?

          if (!plantInCell && !fungusInCell) {
            if (random(1) < 0.2) {
              let newX = nc * cellSize + cellSize / 2;
              let newY = nr * cellSize + cellSize / 2 + (height - soilHeight);
              fungus.push(new Fungus(newX, newY, [nr, nc]));
            }
          }
class Fungus {
  constructor(x, y, soilCell) {
    this.x = x + random(-cellSize / 4, cellSize / 4);
    this.y = y + random(0, cellSize / 4);
    this.soilCell = soilCell;
    this.size = fungusInitialSize;
    this.color = { hue: 280, sat: 70, bright: 60 };
    this.spreadTimer = fungusSpreadInterval;
    this.lifeTimer = fungusLifeSpan;
    this.isDeadFlag = false;
  }

  update() {
    this.size += fungusGrowthRate;
    this.lifeTimer--;

    this.spreadTimer--;
    if (this.spreadTimer <= 0) {
      this.spread();
      this.spreadTimer = fungusSpreadInterval;
    }

    for (let plant of plants) {
      if (!plant.isAlive && plant.decayTimer > 0) {
        if (dist(this.x, this.y, plant.x, plant.y) < this.size + plantBaseLength * 0.5) {
          plant.decayTimer = constrain(
            plant.decayTimer - fungusRapidDecayRate * plantDecayTime,
            0,
            plantDecayTime
          );
        }
      }
    }

    if (this.lifeTimer <= 0) {
      this.isDeadFlag = true;
    }
  }

  draw() {
    push();
    translate(this.x, this.y);
    fill(this.color.hue, this.color.sat, this.color.bright, map(this.lifeTimer, 0, fungusLifeSpan, 0, 100));
    noStroke();
    circle(0, 0, this.size);
    for (let i = 0; i < 3; i++) {
      let offsetAngle = TWO_PI / 3 * i + frameCount * 0.02;
      let offsetX = cos(offsetAngle) * this.size * 0.4;
      let offsetY = sin(offsetAngle) * this.size * 0.4;
      circle(offsetX, offsetY, this.size * 0.6);
    }
    pop();
  }

  spread() {
    if (!this.soilCell) return;
    let [row, col] = this.soilCell;

    for (let dr = -1; dr <= 1; dr++) {
      for (let dc = -1; dc <= 1; dc++) {
        if (dr === 0 && dc === 0) continue;

        let nr = row + dr;
        let nc = col + dc;

        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
          let plantInCell = plants.find(p => p.isAlive && p.soilCell[0] === nr && p.soilCell[1] === nc);
          let decayingPlantInCell = plants.find(p => !p.isAlive && p.decayTimer > 0 && p.soilCell[0] === nr && p.soilCell[1] === nc);
          let fungusInCell = fungus.find(f => f.soilCell[0] === nr && f.soilCell[1] === nc);

          if (!plantInCell && !fungusInCell) {
            if (random(1) < 0.2) {
              let newX = nc * cellSize + cellSize / 2;
              let newY = nr * cellSize + cellSize / 2 + (height - soilHeight);
              fungus.push(new Fungus(newX, newY, [nr, nc]));
            }
          }
        }
      }
    }
  }

  isDead() {
    return this.isDeadFlag;
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Accelerate Nearby Decay for (let plant of plants) { if (!plant.isAlive && plant.decayTimer > 0) { if (dist(this.x, this.y, plant.x, plant.y) < this.size + plantBaseLength * 0.5) { ... } } }

Checks every decaying plant and, if it's close enough to this fungus blob, speeds up its decay timer - fungus literally eats dead plants faster.

for-loop Spread to Neighboring Cells for (let dr = -1; dr <= 1; dr++) { for (let dc = -1; dc <= 1; dc++) { ... if (random(1) < 0.2) { fungus.push(new Fungus(...)); } } }

Checks the 8 neighboring soil cells and, if empty of plants/fungus, has a 20% chance to spawn new fungus there, letting colonies creep outward.

this.size += fungusGrowthRate;
Grows the fungus blob's diameter a tiny bit every frame.
this.spreadTimer--;
Counts down until the next attempt to spread to a neighboring cell.
plant.decayTimer = constrain(plant.decayTimer - fungusRapidDecayRate * plantDecayTime, 0, plantDecayTime);
Subtracts a chunk of decay time from nearby dead plants, making fungus visibly speed up their disappearance.
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
Makes sure the neighboring cell being checked is actually inside the grid bounds before using it.
if (!plantInCell && !fungusInCell) {
Fungus only spreads into empty cells - it won't overwrite a living plant or another fungus patch.
if (random(1) < 0.2) {
Gives each valid empty neighbor cell a 20% chance per spread attempt, so fungus colonies grow organically rather than filling instantly.

Plant (class)

Plant is the most sophisticated class in the sketch, combining L-system string rewriting (generateLSystem), turtle-graphics rendering (draw), a shadow/light competition model, and a full life-death-decay-nutrient-return cycle - together these create emergent forest-like behavior from fairly simple per-plant rules.

🔬 This loop rewrites the plant's axiom string every growth step using this.rules. What happens visually if the rule for square seeds ('F+F-F-F+F') is changed to something with more branch symbols like 'F[+F]F[-F][+F]'?

    let nextAxiom = "";
    for (let i = 0; i < this.axiom.length; i++) {
      let char = this.axiom.charAt(i);
      nextAxiom += this.rules[char] || char;
    }
    this.axiom = nextAxiom;
    this.growthIterations++;

🔬 shadowDeathTimerMax decides how many frames a plant can survive in deep shade before dying. What happens to the forest's density if you make this value much smaller, like 30?

      if (this.lightExposure < shadowDeathThreshold) {
        this.shadowDeathTimer--;
        if (this.shadowDeathTimer <= 0) {
          this.isAlive = false;
          this.decayTimer = plantDecayTime;
        }
      } else {
        this.shadowDeathTimer = shadowDeathTimerMax;
      }
class Plant {
  static nextID = 0;

  constructor(x, y, soilCell, seedShape, parentPlantID = null) {
    this.id = Plant.nextID++;
    plantIDMap.set(this.id, this);

    this.x = x;
    this.y = y;
    this.soilCell = soilCell;
    this.seedShape = seedShape;
    this.plantColor = plantColorMap[seedShape];
    this.originalPlantColor = { ...this.plantColor };

    this.axiom = "F";
    this.rules = {};
    this.angle = 0;
    this.length = plantBaseLength;
    this.growthIterations = 0;
    this.maxIterations = plantMaxIterations;
    this.growthTimer = floor(random(plantGrowthInterval / 2, plantGrowthInterval * 1.5));
    this.branches = [];
    this.isAlive = true;
    this.lightExposure = 1.0;
    this.shadowDeathTimer = shadowDeathTimerMax;

    this.decayTimer = -1;
    this.nutrientsReturned = false;

    this.flowering = false;
    this.flowerTimer = 0;

    this.birthFrame = frameCount;

    this.parentPlantID = parentPlantID;
    this.offspringPlantIDs = [];

    if (this.parentPlantID !== null) {
      let parentPlant = plantIDMap.get(this.parentPlantID);
      if (parentPlant) {
        parentPlant.offspringPlantIDs.push(this.id);
      }
    }

    this.initializeLSystem();
    this.generateLSystem();
  }

  initializeLSystem() {
    switch (this.seedShape) {
      case 'triangle':
        this.rules = { "F": "F[+F]F[-F]F" };
        this.angle = radians(25);
        break;
      case 'square':
        this.rules = { "F": "F+F-F-F+F" };
        this.angle = radians(90);
        break;
      case 'pentagon':
        this.rules = { "F": "F[+F]F[-F]F" };
        this.angle = radians(20);
        break;
      case 'hexagon':
        this.rules = { "F": "F[+F]F[-F]F" };
        this.angle = radians(22.5);
        break;
      default:
        this.rules = { "F": "FF" };
        this.angle = radians(30);
    }
  }

  generateLSystem() {
    if (this.growthIterations >= this.maxIterations) return;

    let nextAxiom = "";
    for (let i = 0; i < this.axiom.length; i++) {
      let char = this.axiom.charAt(i);
      nextAxiom += this.rules[char] || char;
    }
    this.axiom = nextAxiom;
    this.growthIterations++;

    this.branches = [];
    let state = { x: this.x, y: this.y, angle: -HALF_PI };
    let stack = [];
    let currentLength = this.length * pow(plantLengthDecay, this.growthIterations - 1);

    for (let i = 0; i < this.axiom.length; i++) {
      let char = this.axiom.charAt(i);
      switch (char) {
        case 'F':
          let endX = state.x + cos(state.angle) * currentLength;
          let endY = state.y + sin(state.angle) * currentLength;

          let upwardBiasStrength = lerp(0.05, 0.15, 1 - this.lightExposure);
          state.angle = lerp(state.angle, -HALF_PI, upwardBiasStrength);

          for (let otherPlant of plants) {
            if (otherPlant !== this && otherPlant.branches.length > 0 && otherPlant.isAlive) {
              let otherRoot = otherPlant.branches[0];
              let d = dist(endX, endY, otherRoot.endX, otherRoot.endY);
              if (d < 50) {
                let angleToOther = atan2(otherRoot.endY - endY, otherRoot.endX - endX);
                state.angle += radians(map(d, 0, 50, 10, 0)) * (state.angle > angleToOther ? 1 : -1);
              }
            }
          }

          state.angle += random(-radians(5), radians(5));

          this.branches.push({
            startX: state.x,
            startY: state.y,
            endX: endX,
            endY: endY,
            angle: state.angle,
            originalAngle: state.angle,
            length: currentLength,
            depth: this.growthIterations,
            lightExposure: 1.0
          });
          state.x = endX;
          state.y = endY;
          break;
        case '+':
          state.angle += this.angle;
          break;
        case '-':
          state.angle -= this.angle;
          break;
        case '[':
          stack.push({ x: state.x, y: state.y, angle: state.angle });
          break;
        case ']':
          state = stack.pop();
          break;
      }
    }
  }

  update() {
    if (this.isAlive) {
      let [row, col] = this.soilCell;
      let nutrientValue = soilGrid[row][col];

      let consumptionAmount = plantNutrientConsumptionBase * (1 + this.growthIterations * plantNutrientConsumptionMultiplier);
      soilGrid[row][col] = constrain(nutrientValue - consumptionAmount, 0, 100);

      if (this.growthIterations < this.maxIterations) {
        if (nutrientValue > germinationNutrientThreshold / 2) {
          let currentGrowthInterval = plantGrowthInterval;

          if (this.lightExposure < shadowThreshold) {
            currentGrowthInterval = lerp(
              plantGrowthInterval,
              plantGrowthInterval * 5,
              1 - this.lightExposure / shadowThreshold
            );
            currentGrowthInterval = constrain(currentGrowthInterval, plantGrowthInterval, plantGrowthInterval * 5);
          } else {
            currentGrowthInterval = lerp(
              plantGrowthInterval,
              plantGrowthInterval * 0.7,
              (this.lightExposure - shadowThreshold) / (1 - shadowThreshold)
            );
            currentGrowthInterval = constrain(currentGrowthInterval, plantGrowthInterval * 0.7, plantGrowthInterval);
          }

          this.growthTimer--;
          if (this.growthTimer <= 0) {
            if (this.lightExposure > shadowDeathThreshold) {
              this.generateLSystem();
              this.growthTimer = floor(random(currentGrowthInterval / 2, currentGrowthInterval * 1.5));
            } else {
              this.growthTimer = floor(random(currentGrowthInterval * 2, currentGrowthInterval * 3));
            }
          }
        } else {
          this.growthTimer = plantGrowthInterval * 2;
        }
      }

      if (this.lightExposure < shadowDeathThreshold) {
        this.shadowDeathTimer--;
        if (this.shadowDeathTimer <= 0) {
          this.isAlive = false;
          this.decayTimer = plantDecayTime;
        }
      } else {
        this.shadowDeathTimer = shadowDeathTimerMax;
      }

      if (nutrientValue < germinationNutrientThreshold / 4 && this.growthIterations > 0) {
        this.isAlive = false;
        this.decayTimer = plantDecayTime;
      }

      let targetBrightness = lerp(this.originalPlantColor.bright * 0.3, this.originalPlantColor.bright, this.lightExposure);
      let targetSaturation = lerp(this.originalPlantColor.sat * 0.3, this.originalPlantColor.sat, this.lightExposure);

      this.plantColor.bright = lerp(this.plantColor.bright, targetBrightness, 0.1);
      this.plantColor.sat = lerp(this.plantColor.sat, targetSaturation, 0.1);

      if (this.flowering && this.flowerTimer > 0) {
        this.flowerTimer--;
      } else {
        this.flowering = false;
      }

    } else {
      if (this.decayTimer > 0) {
        this.decayTimer--;
        let decayProgress = 1 - (this.decayTimer / plantDecayTime);

        for (let branch of this.branches) {
          branch.angle = lerp(branch.originalAngle, HALF_PI, decayProgress * decayProgress);
        }

        this.plantColor.hue = lerp(this.originalPlantColor.hue, depletedHue, decayProgress);
        this.plantColor.sat = lerp(this.originalPlantColor.sat, depletedSat, decayProgress);
        this.plantColor.bright = lerp(this.originalPlantColor.bright, 10, decayProgress * decayProgress);

        if (this.decayTimer <= 0 && !this.nutrientsReturned) {
          this.returnNutrients();
          this.nutrientsReturned = true;
          totalPlantsDead++;
        }
      }
    }
  }

  draw() {
    if (!this.isAlive && this.decayTimer <= 0) return;

    strokeWeight(plantBranchThickness);
    stroke(this.plantColor.hue, this.plantColor.sat, this.plantColor.bright);

    let state = { x: this.x, y: this.y, angle: -HALF_PI };
    let stack = [];
    let currentLength = plantBaseLength * pow(plantLengthDecay, this.growthIterations - 1);

    for (let char of this.axiom) {
      switch (char) {
        case 'F':
          let endX = state.x + cos(state.angle) * currentLength;
          let endY = state.y + sin(state.angle) * currentLength;
          line(state.x, state.y, endX, endY);
          state.x = endX;
          state.y = endY;
          break;
        case '+':
          state.angle += this.angle;
          break;
        case '-':
          state.angle -= this.angle;
          break;
        case '[':
          stack.push({ x: state.x, y: state.y, angle: state.angle });
          break;
        case ']':
          state = stack.pop();
          break;
      }
    }

    if (this.flowering && this.flowerTimer > 0) {
      push();
      let highestY = height;
      let highestBranchTip = null;
      for (let branch of this.branches) {
        if (branch.endY < highestY) {
          highestY = branch.endY;
          highestBranchTip = { x: branch.endX, y: branch.endY, angle: branch.angle };
        }
      }
      if (highestBranchTip) {
        translate(highestBranchTip.x, highestBranchTip.y);
        rotate(highestBranchTip.angle + HALF_PI);
        noStroke();
        fill(60, 80, 100, map(this.flowerTimer, 0, bloomDuration, 0, 100));
        circle(0, -plantBranchThickness * 2, plantBranchThickness * 3);
      }
      pop();
    }
  }

  returnNutrients() {
    let [row, col] = this.soilCell;
    let nutrientsToReturn =
      plantNutrientConsumptionBase *
      (1 + this.growthIterations * plantNutrientConsumptionMultiplier) *
      plantCompostBonus *
      plantGrowthInterval *
      this.growthIterations;

    const numParticles = floor(nutrientsToReturn / 5) + 1;
    const nutrientsPerParticle = nutrientsToReturn / numParticles;

    for (let i = 0; i < numParticles; i++) {
      nutrientParticles.push(new NutrientParticle(this.x, this.y, nutrientsPerParticle));
    }

    if (random(1) < fungusSpawnChance) {
      let newX = col * cellSize + cellSize / 2;
      let newY = row * cellSize + cellSize / 2 + (height - soilHeight);
      fungus.push(new Fungus(newX, newY, [row, col]));
    }
  }

  isDead() {
    return !this.isAlive && this.decayTimer <= 0;
  }
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

switch-case Choose L-System Rules by Seed Shape switch (this.seedShape) { case 'triangle': this.rules = { "F": "F[+F]F[-F]F" }; this.angle = radians(25); break; ... }

Different seed shapes get different rewriting rules and branch angles, so triangle/pentagon/hexagon seeds grow bushier trees while square seeds grow a more angular, symmetrical shape.

for-loop String Rewriting Step for (let i = 0; i < this.axiom.length; i++) { let char = this.axiom.charAt(i); nextAxiom += this.rules[char] || char; }

The heart of the L-system: replaces every character in the current axiom string using the rule set, growing the string (and thus the plant's complexity) one generation at a time.

switch-case Turtle-Graphics Interpretation switch (char) { case 'F': ... case '+': state.angle += this.angle; break; case '-': state.angle -= this.angle; break; case '[': stack.push(...); break; case ']': state = stack.pop(); break; }

Walks through the axiom string character by character, moving/turning a virtual 'turtle' to build branch segments - F draws forward, + and - turn, and [ ] save/restore position for branching.

conditional Nutrient-and-Light-Driven Growth if (this.growthTimer <= 0) { if (this.lightExposure > shadowDeathThreshold) { this.generateLSystem(); ... } else { ... } }

Only triggers the next L-system growth step once a countdown timer expires, and only if the plant has enough light - shaded plants grow much more slowly or not at all.

conditional Shadow and Starvation Death if (this.lightExposure < shadowDeathThreshold) { this.shadowDeathTimer--; if (this.shadowDeathTimer <= 0) { this.isAlive = false; this.decayTimer = plantDecayTime; } }

Kills a plant if it stays too shaded for too long (shadowDeathTimer runs out) or if local nutrients drop too low, starting its decay countdown.

conditional Decay Animation & Nutrient Return if (this.decayTimer > 0) { this.decayTimer--; let decayProgress = 1 - (this.decayTimer / plantDecayTime); ... if (this.decayTimer <= 0 && !this.nutrientsReturned) { this.returnNutrients(); ... } }

Gradually droops and darkens dead branches as decayProgress increases, then triggers returnNutrients() exactly once when decay finishes.

this.plantColor = plantColorMap[seedShape];
Looks up a starting hue/saturation/brightness color based on which seed shape this plant came from.
this.rules = { "F": "F[+F]F[-F]F" };
This is an L-system production rule: every time 'F' appears in the axiom string, it gets replaced with this longer, branching string, making the plant more complex each growth step.
nextAxiom += this.rules[char] || char;
Applies the rule to each character if one exists (like 'F'), otherwise keeps the character unchanged (like '+', '-', '[', ']').
let currentLength = this.length * pow(plantLengthDecay, this.growthIterations - 1);
Each successive growth iteration draws shorter branches, since plantLengthDecay (0.7) is less than 1 and gets raised to higher powers - this creates the classic 'twigs get thinner/shorter' fractal look.
let upwardBiasStrength = lerp(0.05, 0.15, 1 - this.lightExposure);
Shaded plants (low lightExposure) get a stronger pull to grow straight up, simulating phototropism - plants bending toward light.
let d = dist(endX, endY, otherRoot.endX, otherRoot.endY);
Checks how close this new branch tip is to the base of another plant, so plants can subtly steer away from crowding each other.
case 'F':
The 'F' command means 'move forward, drawing a line' - the basic building block of every L-system branch.
case '[':
Saves the turtle's current position and angle onto a stack, marking the start of a side-branch.
case ']':
Restores the previously saved position and angle, returning the turtle to the branching point so the next segment starts from there instead of continuing the side-branch.
let consumptionAmount = plantNutrientConsumptionBase * (1 + this.growthIterations * plantNutrientConsumptionMultiplier);
Bigger, more-grown plants consume more nutrients per frame than young seedlings.
if (this.lightExposure < shadowThreshold) {
Below this exposure level, the plant is considered shaded and its growth interval is stretched out (slower growth).
if (this.growthTimer <= 0) {
Only attempts another L-system rewrite once this countdown reaches zero, pacing growth over time instead of instantly maxing out.
branch.angle = lerp(branch.originalAngle, HALF_PI, decayProgress * decayProgress);
As a plant decays, its branches gradually rotate toward pointing straight down (HALF_PI), visually 'wilting' over time.
if (this.decayTimer <= 0 && !this.nutrientsReturned) {
Ensures returnNutrients() runs exactly once per plant, right when its decay animation finishes.
const numParticles = floor(nutrientsToReturn / 5) + 1;
Splits the total nutrient payoff into several smaller particles rather than one big chunk, so decay looks like a scattering of glowing motes.

📦 Key Variables

cellSize number

Pixel size of each square soil grid cell, controlling grid resolution.

const cellSize = 10;
soilHeight number

Height in pixels of the soil band at the bottom of the canvas.

const soilHeight = 150;
rows number

Number of rows in the soil grid, computed from soilHeight and cellSize.

let rows;
cols number

Number of columns in the soil grid, computed from canvas width and cellSize.

let cols;
soilGrid array

2D array storing each cell's nutrient level (0-100), the core simulation state.

let soilGrid;
noiseOffset number

Slowly incrementing z-coordinate for Perlin noise so the soil texture drifts over time.

let noiseOffset = 0;
noiseScale number

Controls how zoomed-in the Perlin noise pattern is across the grid (chunkiness).

const noiseScale = 0.05;
noiseDriftRate number

How fast noiseOffset advances each frame - governs the 'breathing' speed of the soil.

const noiseDriftRate = 0.0005;
noiseNutrientInfluence number

Maximum nutrient swing caused by noise each frame.

const noiseNutrientInfluence = 2;
nutrientWells array

List of nutrient well objects (x position and radius) that regenerate nearby soil.

let nutrientWells = [];
minWells number

Minimum number of nutrient wells created at startup.

const minWells = 5;
maxWells number

Maximum number of nutrient wells created at startup.

const maxWells = 8;
wellRadius number

Radius in pixels of each nutrient well's influence.

const wellRadius = 70;
wellRegenRate number

Amount of nutrient added per frame to cells near a well's center.

const wellRegenRate = 0.15;
diffusionRate number

Fraction of each cell's nutrient value blended with its neighbors' average every frame.

const diffusionRate = 0.03;
seeds array

All active Seed objects currently falling, dormant, or about to germinate.

let seeds = [];
seedSpawnTimer number

Countdown of frames until the next single seed spawns.

let seedSpawnTimer = 0;
seedSpawnIntervalMin number

Minimum frames between individual seed spawns.

const seedSpawnIntervalMin = 60;
seedSpawnIntervalMax number

Maximum frames between individual seed spawns.

const seedSpawnIntervalMax = 120;
seedSize number

Pixel size used to draw each seed shape.

const seedSize = 8;
seedGravity number

Downward acceleration applied to falling seeds each frame.

const seedGravity = 0.1;
seedWindForce number

Maximum random horizontal push applied to seeds each frame, simulating wind.

const seedWindForce = 0.05;
germinationNutrientThreshold number

Minimum soil nutrient value required for a seed to germinate into a plant.

const germinationNutrientThreshold = 40;
nutrientConsumptionOnGermination number

Nutrients subtracted from the soil the instant a seed germinates.

const nutrientConsumptionOnGermination = 30;
dormantRecheckInterval number

Frames between a dormant seed rechecking whether soil nutrients are now high enough to germinate.

const dormantRecheckInterval = 60;
nutrientParticles array

All active NutrientParticle objects falling or dissolving back into the soil.

let nutrientParticles = [];
particleSizeMin number

Smallest possible size for a nutrient particle.

const particleSizeMin = 2;
particleSizeMax number

Largest possible size for a nutrient particle.

const particleSizeMax = 4;
particleGravity number

Downward acceleration applied to falling nutrient particles.

const particleGravity = 0.05;
particleLifeSpan number

Frames a particle lingers, fading out, after it dissolves into the soil.

const particleLifeSpan = 120;
fungus array

All active Fungus blob objects growing and spreading across decaying plant sites.

let fungus = [];
fungusSpawnChance number

Probability that fungus appears when a plant finishes decaying.

const fungusSpawnChance = 0.3;
fungusInitialSize number

Starting diameter of a newly spawned fungus blob.

const fungusInitialSize = 8;
fungusGrowthRate number

How much a fungus blob's size increases every frame.

const fungusGrowthRate = 0.02;
fungusSpreadInterval number

Frames between a fungus blob's attempts to spread to a neighboring soil cell.

const fungusSpreadInterval = 120;
fungusLifeSpan number

Total frames a fungus blob exists before disappearing.

const fungusLifeSpan = 600;
fungusRapidDecayRate number

How much extra decay a nearby fungus blob applies to a dead plant per frame.

const fungusRapidDecayRate = 0.1;
seedRainActive boolean

Whether a temporary burst of extra seeds is currently falling.

let seedRainActive = false;
seedRainTimer number

Countdown of remaining seeds to drop during an active seed rain event.

let seedRainTimer = 0;
seedRainCount number

Total number of seeds dropped during one seed rain event.

const seedRainCount = 20;
seedRainInterval number

Frames between the start of successive seed rain events.

const seedRainInterval = 1000;
droughtActive boolean

Whether a drought event is currently reducing nutrient regeneration and depleting soil.

let droughtActive = false;
droughtTimer number

Countdown of remaining frames in the current drought event.

let droughtTimer = 0;
droughtDuration number

Total length in frames of a drought event.

const droughtDuration = 300;
bloomActive boolean

Whether a bloom event is currently making living plants flower.

let bloomActive = false;
bloomTimer number

Countdown of remaining frames in the current bloom event.

let bloomTimer = 0;
bloomDuration number

Total length in frames of a bloom event.

const bloomDuration = 180;
bloomInterval number

Frames between the start of successive bloom events.

const bloomInterval = 2000;
plants array

All active Plant objects, alive or still decaying.

let plants = [];
plantIDMap object

A Map from plant ID to Plant object, used to quickly look up parents/offspring for genealogy lines.

let plantIDMap = new Map();
totalPlantsAlive number

Current count of living plants, recorded each frame for the timeline chart.

let totalPlantsAlive = 0;
totalPlantsDead number

Cumulative count of plants that have fully decayed, used in the timeline chart.

let totalPlantsDead = 0;
plantGrowthInterval number

Base number of frames between a plant's L-system growth steps.

const plantGrowthInterval = 30;
plantMaxIterations number

Maximum number of L-system rewriting generations a plant can reach.

const plantMaxIterations = 4;
plantBaseLength number

Initial branch segment length before length-decay is applied.

const plantBaseLength = 10;
plantLengthDecay number

Multiplier applied to branch length at each growth iteration, shrinking twigs over generations.

const plantLengthDecay = 0.7;
plantBranchThickness number

Stroke weight used to draw plant branch lines.

const plantBranchThickness = 1.5;
plantNutrientConsumptionBase number

Base nutrient amount a plant consumes from its soil cell each frame.

const plantNutrientConsumptionBase = 0.05;
plantNutrientConsumptionMultiplier number

Extra nutrient consumption added per L-system growth iteration a plant has reached.

const plantNutrientConsumptionMultiplier = 0.5;
plantDecayTime number

Total frames it takes a dead plant to fully decay and disappear.

const plantDecayTime = 200;
plantCompostBonus number

Multiplier applied to nutrients returned to the soil when a plant decays.

const plantCompostBonus = 1.5;
lightNoiseOffset number

Drifting offset used to animate the shimmering light band overlay.

let lightNoiseOffset = 0;
lightNoiseScale number

Controls how wide/frequent the drifting light bands appear.

const lightNoiseScale = 0.005;
lightDriftRate number

How fast the light band pattern drifts across the soil each frame.

const lightDriftRate = 0.0001;
lightBrightnessMin number

Minimum brightness/alpha for the light overlay strips.

const lightBrightnessMin = 0;
lightBrightnessMax number

Maximum brightness/alpha for the light overlay strips.

const lightBrightnessMax = 10;
shadowThreshold number

Light exposure level below which a plant's growth slows down.

const shadowThreshold = 0.6;
shadowDeathThreshold number

Light exposure level below which a plant stops growing and starts a death countdown.

const shadowDeathThreshold = 0.3;
shadowDeathTimerMax number

Frames a plant can survive in deep shade before dying.

const shadowDeathTimerMax = 180;
aliveHistory array

Rolling record of totalPlantsAlive per frame, used to draw the timeline's green curve.

let aliveHistory = [];
deadHistory array

Rolling record of totalPlantsDead per frame, used to draw the timeline's gray curve.

let deadHistory = [];
fertilityHistory array

Rolling record of average soil fertility per frame, used to draw the timeline's amber curve.

let fertilityHistory = [];
historyLength number

Maximum number of frames kept in each history array before old entries are shifted out.

const historyLength = 200;
timelineHeight number

Pixel height of the timeline chart bar at the bottom of the screen.

const timelineHeight = 60;
showGenealogyWeb boolean

Whether to draw connecting lines between parent and offspring plants, toggled by the G key.

let showGenealogyWeb = false;
fastForward boolean

Whether the simulation should run multiple update steps per rendered frame, toggled by the F key.

let fastForward = false;
stars array

Precomputed star objects (position, size, twinkle timing) for the night sky.

let stars = [];
numStars number

How many stars are generated for the starfield.

const numStars = 200;
depletedHue number

HSB hue used for nutrient-poor soil (brownish).

const depletedHue = 30;
richHue number

HSB hue used for nutrient-rich soil (amber/reddish).

const richHue = 15;
plantColorMap object

Lookup table mapping each seed shape to its starting hue/saturation/brightness color.

const plantColorMap = { triangle: { hue: 20, sat: 80, bright: 60 } };

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

PERFORMANCE updateSimulationLogic()

Several nested loops iterate over the entire soil grid every single frame (noise drift, well regeneration for every well, diffusion, drought, and the fertility sum for the timeline), and well regeneration in particular loops over ALL cells for EVERY well even though only cells within wellRadius are affected.

💡 Precompute each well's bounding row/column range from its radius and only loop over that smaller sub-rectangle of the grid, and consider caching the fertility sum incrementally instead of re-summing the whole grid every frame.

BUG Plant.generateLSystem()

The neighbor-avoidance check only compares each new branch tip against otherPlant.branches[0] (the root of every other plant), not the actual nearest branch, so plants can still grow directly into another plant's higher branches without steering away.

💡 Compare against the closest branch of each other plant (e.g. loop through otherPlant.branches and track the minimum distance) rather than assuming index 0 is always the relevant one.

PERFORMANCE Fungus.spread()

Array.find() is called three times (plantInCell, decayingPlantInCell, fungusInCell) inside a doubly-nested 3x3 loop for every fungus blob every fungusSpreadInterval frames, which is O(n) per lookup and can get slow as plants/fungus counts grow.

💡 Maintain a lookup structure (e.g. a Map keyed by 'row,col') for plants and fungus occupying each soil cell, updated when objects are created/removed, so occupancy checks become O(1) instead of scanning the whole array.

STYLE Fungus.spread()

The variable decayingPlantInCell is computed with .find() but never actually used anywhere in the function.

💡 Remove the unused decayingPlantInCell line, or use it to let fungus specifically target/avoid decaying-plant cells if that was the original intent.

FEATURE General / UI

Fast-forward and genealogy toggles (F and G keys) work but there's no on-screen hint telling first-time viewers these controls exist.

💡 Draw a small, semi-transparent text hint in a corner (e.g. 'F: fast-forward G: genealogy web') so the interactive features are discoverable without reading the source code.

🔄 Code Flow

Code flow showing setup, initializesoil, initializestars, updatesimulationlogic, drawstars, drawvisuals, draw, windowresized, keytyped, polygon, seed, nutrientparticle, fungus, plant

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> initializesoil[initializesoil] setup --> initializestars[initializestars] setup --> draw[draw loop] draw --> updatesimulationlogic[updatesimulationlogic] updatesimulationlogic --> event-management[Event Management] event-management --> wells-loop[wells-loop] wells-loop --> soilgrid-build[soilgrid-build] soilgrid-build --> noise-drift[noise-drift] noise-drift --> well-regen[well-regen] well-regen --> diffusion[diffusion] diffusion --> shadow-calc[shadow-calc] shadow-calc --> update-collections[update-collections] update-collections --> plant-growth-update[plant-growth-update] plant-growth-update --> plant-death-checks[plant-death-checks] plant-death-checks --> plant-decay-update[plant-decay-update] draw --> drawstars[drawstars] drawstars --> stars-loop[stars-loop] stars-loop --> twinkle-loop[twinkle-loop] twinkle-loop --> drawvisuals[drawvisuals] drawvisuals --> soil-draw[soil-draw] soil-draw --> light-layer[light-layer] light-layer --> hover-check[hover-check] hover-check --> timeline-shapes[timeline-shapes] click setup href "#fn-setup" click initializesoil href "#fn-initializesoil" click initializestars href "#fn-initializestars" click draw href "#fn-draw" click updatesimulationlogic href "#fn-updatesimulationlogic" click event-management href "#sub-event-management" click wells-loop href "#sub-wells-loop" click soilgrid-build href "#sub-soilgrid-build" click noise-drift href "#sub-noise-drift" click well-regen href "#sub-well-regen" click diffusion href "#sub-diffusion" click shadow-calc href "#sub-shadow-calc" click update-collections href "#sub-update-collections" click plant-growth-update href "#sub-plant-growth-update" click plant-death-checks href "#sub-plant-death-checks" click plant-decay-update href "#sub-plant-decay-update" click drawstars href "#fn-drawstars" click stars-loop href "#sub-stars-loop" click twinkle-loop href "#sub-twinkle-loop" click drawvisuals href "#fn-drawvisuals" click soil-draw href "#sub-soil-draw" click light-layer href "#sub-light-layer" click hover-check href "#sub-hover-check" click timeline-shapes href "#sub-timeline-shapes"

❓ Frequently Asked Questions

What visual experience does the Fractal Forest sketch offer?

The Fractal Forest sketch creates a mesmerizing landscape where seeds drift into a glowing soil bed, plants sprout with intricate branching patterns, and fungus blooms amidst gently swirling nutrients.

How can users interact with the Fractal Forest sketch?

Users can explore the living landscape by moving their mouse, which influences the soft breathing and shifting of the environment.

What creative coding techniques are showcased in the Fractal Forest sketch?

The sketch demonstrates L-system-inspired logic for plant growth, Perlin noise for organic movement, and nutrient diffusion to simulate a dynamic ecosystem.

Preview

Fractal Forest - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Fractal Forest - Code flow showing setup, initializesoil, initializestars, updatesimulationlogic, drawstars, drawvisuals, draw, windowresized, keytyped, polygon, seed, nutrientparticle, fungus, plant
Code Flow Diagram