FRACTAL GARDEN2

This sketch simulates a living cross-section of soil where Perlin-noise nutrients drift and diffuse, seeds fall from the sky and germinate when the ground is fertile enough, and surviving seeds grow into branching plants using L-system turtle graphics. Random events like seed showers, droughts, and blooms periodically reshape the miniature ecosystem, while a scrolling timeline tracks population and soil fertility over time.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up nutrient diffusion — Raising diffusionRate makes soil fertility blur and even out between neighboring cells much faster, smoothing out the colorful patches.
  2. Make seeds fall much faster — Increasing seedGravity makes falling seeds accelerate downward more quickly, landing on the soil sooner and with less drifting.
  3. Grow bushier, more complex plants — Raising plantMaxIterations lets each plant run through more L-system rewrite generations, producing noticeably bushier, more branched growth.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch renders a breathing strip of soil at the bottom of the screen, colored cell-by-cell according to a nutrient value that drifts using Perlin noise, regenerates near underground 'wells', and diffuses between neighboring cells like heat spreading through metal. Seeds made of different polygon shapes drift down from the top of the canvas, land on the soil, and either germinate immediately or lie dormant until the nutrients around them recover. Germinated seeds become plants that grow branch-by-branch using an L-system (Lindenmayer system) - a string-rewriting technique borrowed from botany that produces convincing branching patterns from a few simple grammar rules. Random events - seed rain, drought, and bloom - periodically override the normal rules to keep the ecosystem visually dynamic.

The code is organized into two big 'phases' called every frame: updateSimulationLogic(), which advances every piece of state (nutrients, seeds, plants, particles, events, and history), and drawVisuals(), which renders all of it without touching any state. Three ES6 classes - Seed, NutrientParticle, and Plant - encapsulate their own update()/draw() logic, and the Plant class shows a complete implementation of L-system turtle graphics: rewriting a string of instructions and then walking a virtual turtle through it to generate line segments. Studying this sketch teaches grid-based diffusion simulation, Perlin noise as a 'natural randomness' generator, simple ecological rules (light competition, nutrient depletion, decay and compost), and how to structure a moderately large p5.js project into readable, single-purpose functions.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, switches to HSB color mode, and calls initializeSoil() to build a 2D nutrient grid seeded with Perlin noise plus a handful of randomly placed 'nutrient wells' that will keep regenerating fertility nearby.
  2. Every frame, draw() calls updateSimulationLogic() (which can run multiple times per frame if fast-forward is on) to advance the noise drift, well regeneration, and neighbor-averaging diffusion of the soil grid, then update every seed, plant, and nutrient particle in the scene.
  3. Falling seeds are affected by gravity and wind; when they hit the soil they check the nutrient level in their cell and either germinate instantly (spawning a new Plant) or go dormant and periodically recheck until conditions improve.
  4. Each Plant grows by rewriting an L-system string every growthTimer interval and re-walking it with turtle graphics to produce new branch segments, biased to lean toward open light and away from crowding neighbors; a shadow map computed each frame determines how much light every branch receives.
  5. Plants that sit in nutrient-poor soil or deep shadow for too long die and slowly decay, drooping their branches and eventually releasing NutrientParticle objects that fall and dissolve back into the soil, closing the nutrient cycle.
  6. Random timed events - seed showers, droughts that pause well regeneration, and blooms that make living plants flower - periodically interrupt the steady state, and a scrolling timeline at the bottom plots living plants, cumulative deaths, and average soil fertility over the last 200 frames.

🎓 Concepts You'll Learn

2D grid-based diffusion simulationPerlin noise for organic randomnessL-systems and turtle graphicsHSB color mode and color mappingState machines (falling/dormant/germinating, alive/decaying)Object-oriented classes in p5.jsFrame-based timers and event schedulingData visualization with a scrolling timeline

📝 Code Breakdown

setup()

setup() runs exactly once when the sketch starts. It's the right place to configure the canvas and color system before anything gets drawn, and to call one-time initialization helpers like initializeSoil().

function setup() {
  // Create a canvas that fills the entire browser window.
  createCanvas(windowWidth, windowHeight);

  // Set the color mode to HSB.
  // The parameters define the maximum values for Hue, Saturation, and Brightness.
  // Here, Hue goes from 0-360, and Saturation/Brightness go from 0-100.
  colorMode(HSB, 360, 100, 100);

  // Disable drawing outlines (strokes) around shapes.
  noStroke(); // Seeds will have strokes, plants will use stroke() for branches

  // Initialize the soil grid and nutrient wells.
  initializeSoil();

  // Set the initial seed spawn timer.
  seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
}
Line-by-line explanation (5 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window so the soil strip always stretches edge to edge.
colorMode(HSB, 360, 100, 100);
Switches from the default RGB color system to HSB (Hue/Saturation/Brightness), which makes it much easier to smoothly fade between colors like depleted brown soil and rich amber soil.
noStroke();
Turns off shape outlines by default; individual objects like plant branches turn stroke() back on when they need it.
initializeSoil();
Calls the helper function that builds the nutrient grid and places the random nutrient wells - this is where all the soil setup logic actually lives.
seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
Picks a random number of frames to wait before the very first seed spawns, so seeds don't all begin falling on exactly frame 0.

initializeSoil()

This function rebuilds the entire soil state from scratch, which is why it's also called from windowResized() - any time the canvas size changes, the grid dimensions need to be recalculated to match.

🔬 This loop scatters nutrient wells randomly. What happens if you replace random(width) with a fixed spacing like (k / numWells) * width so the wells are evenly spread instead of random?

  nutrientWells = [];
  let numWells = floor(random(minWells, maxWells + 1)); // Random number of wells between min and max
  for (let k = 0; k < numWells; k++) {
    nutrientWells.push({
      x: random(width), // Random x-position across the canvas
      radius: wellRadius
    });
  }
function initializeSoil() {
  // Calculate the number of rows and columns for the soil grid.
  // We only fill the bottom 'soilHeight' pixels.
  rows = floor(soilHeight / cellSize);
  cols = floor(width / cellSize);

  // Create a 2D array (grid) and initialize all cells with a base nutrient value.
  // Add some initial Perlin noise for variation.
  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);
  }));

  // Clear existing wells and create new ones.
  nutrientWells = [];
  let numWells = floor(random(minWells, maxWells + 1)); // Random number of wells between min and max
  for (let k = 0; k < numWells; k++) {
    nutrientWells.push({
      x: random(width), // Random x-position across the canvas
      radius: wellRadius
    });
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

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

Creates a rows x cols 2D array and fills each cell with a Perlin-noise-based starting nutrient value between roughly 30 and 70.

for-loop Create Nutrient Wells for (let k = 0; k < numWells; k++) { nutrientWells.push({...}); }

Scatters a random number of nutrient wells (fertility hotspots) across the width of the canvas.

rows = floor(soilHeight / cellSize);
Figures out how many grid rows fit in the fixed soilHeight strip.
cols = floor(width / cellSize);
Figures out how many grid columns fit across the current canvas width, so the grid always matches the window size.
let initialNutrient = 50 + map(noise(j * 0.1, i * 0.1), 0, 1, -20, 20);
Starts each cell around 50% nutrients, then nudges it up or down using 2D Perlin noise so the initial soil isn't perfectly uniform.
let numWells = floor(random(minWells, maxWells + 1));
Rolls a random whole number of wells between minWells and maxWells for this particular run.
x: random(width), // Random x-position across the canvas
Each well only needs an x position (and fixed radius) because it's assumed to sit at the vertical center of the soil strip.

updateSimulationLogic()

This function is the 'brain' of the sketch - it never draws anything, it only advances state. Separating update logic from drawing logic like this (a common pattern) makes it much easier to add features like fast-forward, since you can simply call the update function multiple times per frame without redrawing multiple times.

🔬 This loop is what makes the soil 'breathe'. What happens visually if you multiply noiseDriftRate by 20 so the pattern drifts much faster than intended?

  noiseOffset += noiseDriftRate;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let noiseValue = noise(j * noiseScale, i * noiseScale + noiseOffset);
      // Add a small, random-like perturbation based on noise.
      soilGrid[i][j] += map(noiseValue, 0, 1, -noiseNutrientInfluence, noiseNutrientInfluence);
      // Clamp nutrient value to stay within 0-100 range.
      soilGrid[i][j] = constrain(soilGrid[i][j], 0, 100);
    }
  }
function updateSimulationLogic() {
  // Calculate the y-coordinate where the soil area begins.
  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) { // Trigger drought after seed rain, avoid overlap
    droughtActive = true;
    droughtTimer = droughtDuration;
  }
  if (droughtActive) {
    droughtTimer--;
    if (droughtTimer <= 0) droughtActive = false;
  }

  // Bloom
  if (frameCount % bloomInterval === 0 && !droughtActive) { // No bloom during drought
    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:
  // Slowly shift the noise pattern to make the soil "breathe".
  noiseOffset += noiseDriftRate;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let noiseValue = noise(j * noiseScale, i * noiseScale + noiseOffset);
      // Add a small, random-like perturbation based on noise.
      soilGrid[i][j] += map(noiseValue, 0, 1, -noiseNutrientInfluence, noiseNutrientInfluence);
      // Clamp nutrient value to stay within 0-100 range.
      soilGrid[i][j] = constrain(soilGrid[i][j], 0, 100);
    }
  }

  // 2. Nutrient Well Regeneration:
  if (!droughtActive) { // Wells only regenerate when not in drought
    // Regenerate nutrients around each well.
    for (let well of nutrientWells) {
      for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
          // Calculate the center pixel coordinates of the current soil cell.
          let cellX = j * cellSize + cellSize / 2;
          let cellY = i * cellSize + cellSize / 2 + soilStartY;

          // Calculate the distance from the well's center to the cell's center.
          let d = dist(well.x, soilStartY + soilHeight / 2, cellX, cellY);

          // If the cell is within the well's radius:
          if (d < well.radius) {
            // Regeneration amount decays with distance from the well.
            let regenAmount = wellRegenRate * (1 - d / well.radius);
            soilGrid[i][j] = constrain(soilGrid[i][j] + regenAmount, 0, 100);
          }
        }
      }
    }
  }

  // 3. Nutrient Diffusion:
  // Create a new grid to store the next state of nutrient values.
  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;

      // Check immediate neighbors (up, down, left, right) and sum their nutrient values.
      // Handle boundary conditions so we don't go out of bounds.
      if (j > 0) { // Left neighbor
        neighborsNutrientSum += soilGrid[i][j - 1];
        numNeighbors++;
      }
      if (j < cols - 1) { // Right neighbor
        neighborsNutrientSum += soilGrid[i][j + 1];
        numNeighbors++;
      }
      if (i > 0) { // Top neighbor
        neighborsNutrientSum += soilGrid[i - 1][j];
        numNeighbors++;
      }
      if (i < rows - 1) { // Bottom neighbor
        neighborsNutrientSum += soilGrid[i + 1][j];
        numNeighbors++;
      }

      // Calculate the average nutrient value of the neighbors.
      let avgNeighborNutrients = numNeighbors > 0 ? neighborsNutrientSum / numNeighbors : currentNutrient;

      // Calculate the new nutrient value for the cell.
      // A percentage of the current nutrient diffuses away, and an equal percentage
      // of the average neighbor nutrient diffuses in.
      nextSoilGrid[i][j] = constrain(currentNutrient * (1 - diffusionRate) + avgNeighborNutrients * diffusionRate, 0, 100);
    }
  }
  // Replace the old soil grid with the newly calculated one.
  soilGrid = nextSoilGrid;


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


  // === Shadow Calculation and Light Exposure for Plants ===
  let shadowMap = Array(cols).fill(0); // Stores the lowest y-coordinate (highest point) that is shadowed at each x column. 0 = top of canvas, no shadow yet.

  // Sort plants by their highest point (lowest y-coordinate) from top to bottom.
  let sortedPlants = [...plants].sort((a, b) => {
    // Only consider living plants for shadow casting
    if (!a.isAlive && !b.isAlive) return 0;
    if (!a.isAlive) return 1; // Dead plants don't cast shadows
    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; // Sort ascending by highest point (lowest y)
  });


  for (let plant of sortedPlants) {
    if (!plant.isAlive) continue; // Only living plants cast shadows

    let plantTotalLightExposure = 0;
    let plantBranchCount = 0;

    for (let branch of plant.branches) {
      // Determine the x range of this branch in soil cells
      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);

      // Determine the y-coordinate where this branch blocks light
      let branchYBlock = floor(min(branch.startY, branch.endY));

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

      for (let x = branchX1; x <= branchX2; x++) {
        // If the current branch's highest point is above the highest shadow cast so far in this column, it's illuminated.
        if (branchYBlock < shadowMap[x]) {
          unshadowedWidth++;
        }
      }

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

      // Update the global shadow map for this branch's blocking effect
      for (let x = branchX1; x <= branchX2; x++) {
        shadowMap[x] = max(shadowMap[x], branchYBlock); // Update with the highest point of this branch
      }
    }

    // Calculate average light exposure for the entire plant
    plant.lightExposure = plantBranchCount > 0 ? plantTotalLightExposure / plantBranchCount : 1.0; // Default to full light if no branches (shouldn't happen)
  }


  // === Seed Spawning ===
  // Normal seed spawning only if not in seed rain event
  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();

    // Remove seeds that have germinated or fallen below the canvas (shouldn't happen with soil collision)
    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()) {
      // Plant is fully decayed and removed
      plantIDMap.delete(plants[i].id); // Remove from map
      plants.splice(i, 1);
    }
  }
  totalPlantsAlive = livingPlantsCount;


  // === 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); // Cumulative count
  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 (8 lines)

🔧 Subcomponents:

conditional Seed Rain / Drought / Bloom Triggers if (frameCount % seedRainInterval === 0 && !droughtActive) { ... }

Uses the frame counter's remainder (modulo) to fire seed showers, droughts, and blooms at regular intervals, while preventing them from overlapping.

for-loop Perlin Noise Drift for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { ... } }

Nudges every soil cell's nutrient value using slowly shifting Perlin noise, giving the soil a subtle 'breathing' texture.

for-loop Well Regeneration for (let well of nutrientWells) { for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { ... } } }

Adds nutrients back to cells near each well, with the boost fading out the further a cell is from the well's center.

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

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

for-loop Shadow Map / Light Exposure for (let plant of sortedPlants) { ... }

Processes plants from tallest to shortest, marking which soil columns are shadowed so lower branches and plants get less light exposure.

for-loop Update & Prune Seeds, Plants, Particles for (let i = seeds.length - 1; i >= 0; i--) { seeds[i].update(); ... }

Iterates each array backwards so entries can be safely removed with splice() while updating every seed, plant, and nutrient particle.

if (frameCount % seedRainInterval === 0 && !droughtActive) {
Every seedRainInterval frames (and only if there's no drought), this condition becomes true and kicks off a seed shower.
noiseOffset += noiseDriftRate;
Slowly increases the noise offset every frame - this is what makes the soil pattern slide and shift over time rather than staying frozen.
let d = dist(well.x, soilStartY + soilHeight / 2, cellX, cellY);
Measures how far a soil cell is from a well's center so regeneration strength can fall off with distance.
nextSoilGrid[i][j] = constrain(currentNutrient * (1 - diffusionRate) + avgNeighborNutrients * diffusionRate, 0, 100);
The core diffusion formula: mix mostly the cell's own value with a small percentage of its neighbors' average, clamped to a valid 0-100 range.
let sortedPlants = [...plants].sort((a, b) => { ... });
Copies the plants array and sorts it by height so shadows can be cast correctly from the tallest plants down to the shortest.
branch.lightExposure = unshadowedWidth / totalWidth;
Converts how much of a branch's horizontal span is unshadowed into a 0-1 fraction used later to slow growth or trigger death in deep shade.
for (let i = seeds.length - 1; i >= 0; i--) {
Looping backwards lets the code call seeds.splice(i, 1) to remove germinated seeds mid-loop without skipping any elements.
if (aliveHistory.length > historyLength) aliveHistory.shift();
Keeps the timeline history arrays capped at historyLength entries by dropping the oldest value once the array grows too long, like a sliding window.

drawVisuals()

drawVisuals() never modifies simulation state - it only reads variables and calls drawing functions. This read-only discipline is what makes it safe to call updateSimulationLogic() multiple times per frame (fast-forward) without ever drawing more than once per frame.

🔬 This loop paints subtle light bands. What happens if you boost lightBrightnessMax to 60 so the bands become much more visible?

  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, 0, cellSize, height);
  }
function drawVisuals() {
  // === Background ===
  // Clear the canvas each frame with a very dark background.
  // The soil is drawn on top of this. The area above soilStartY remains this dark gray.
  // If you meant the entire page background should be different, you'd change style.css.
  background(0, 0, 5); // HSB: hue=0, sat=0, bright=5 (a very dark gray)

  // Calculate the y-coordinate where the soil area begins.
  const soilStartY = height - soilHeight;

  // === Light Layer Visualization ===
  push();
  noStroke();
  // Draw semi-transparent light bands
  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, 0, cellSize, height);
  }
  pop();


  // === Draw Soil Cells ===
  // Iterate through the soil grid and draw each cell with its corresponding color.
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let nutrientValue = soilGrid[i][j];

      // Map the nutrient value (0-100) to HSB color components.
      // Hue: Transitions from brown/gray (depleted) to amber/red (rich).
      let currentHue = map(nutrientValue, 0, 100, depletedHue, richHue);
      // Saturation: Transitions from grayish to vivid.
      let currentSat = map(nutrientValue, 0, 100, depletedSat, richSat);
      // Brightness: Transitions from dark to bright.
      let currentBright = map(nutrientValue, 0, 100, depletedBright, richBright);

      // Set the fill color for the cell.
      fill(currentHue, currentSat, currentBright);

      // Draw the rectangle for the current cell.
      // The y-coordinate is offset by 'soilStartY' to place the soil at the bottom.
      rect(j * cellSize, i * cellSize + soilStartY, cellSize, cellSize);
    }
  }


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


  // === Draw Genealogy Web (if toggled) ===
  if (showGenealogyWeb) {
    push();
    stroke(0, 0, 50, 15); // Faint gray lines
    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(); // Seeds will draw their own shapes, but reset noStroke for others
  for (let seed of seeds) {
    seed.draw();
  }


  // === Mouse Hover Plant Info ===
  let hoveredPlant = null;
  // Iterate in reverse to detect the topmost plant
  for (let i = plants.length - 1; i >= 0; i--) {
    let plant = plants[i];
    if (plant.isAlive || plant.decayTimer > 0) { // Hover over living or decaying plants
      // Simple hover detection around plant's root
      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); // White, semi-transparent background
    noStroke();
    rect(0, 0, 150, 80); // Adjust size as needed

    fill(0); // Black text
    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);

    // Draw family tree lines for hovered plant
    if (hoveredPlant.offspringPlantIDs.length > 0) {
      push();
      stroke(0, 0, 50, 50); // Semi-transparent gray
      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);
  // Draw translucent background for the timeline
  fill(0, 0, 0, 50); // Dark gray, semi-transparent
  noStroke();
  rect(0, 0, width, timelineHeight);

  // Calculate max values for scaling
  const maxAlive = max(aliveHistory);
  const maxDead = max(deadHistory);
  const maxFertility = max(fertilityHistory);
  const maxCombined = max(maxAlive, maxDead, maxFertility); // Use a common max for better comparison

  // Draw Alive History (Green)
  noFill();
  stroke(120, 80, 70); // Vibrant green
  strokeWeight(2);
  beginShape();
  vertex(0, timelineHeight); // Start at bottom left
  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); // Scale to timeline height
    vertex(x, y);
  }
  vertex(width, timelineHeight); // End at bottom right
  endShape();

  // Draw Dead History (Gray)
  stroke(0, 0, 50); // Gray
  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();

  // Draw Fertility History (Amber)
  stroke(richHue, richSat, richBright); // Rich amber
  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); // Fertility is 0-100, so use that directly
    vertex(x, y);
  }
  vertex(width, timelineHeight);
  endShape();

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

🔧 Subcomponents:

for-loop Light Band Visualization for (let x = 0; x < width; x += cellSize) { ... }

Draws slow-drifting semi-transparent vertical strips using Perlin noise to simulate dappled light falling on the scene.

for-loop Soil Cell Rendering fill(currentHue, currentSat, currentBright); rect(j * cellSize, i * cellSize + soilStartY, cellSize, cellSize);

Colors and draws every cell of the soil grid, mapping its nutrient value to a hue/saturation/brightness gradient.

conditional Genealogy Web if (showGenealogyWeb) { ... }

When toggled on with the G key, draws faint lines connecting every parent plant to its offspring.

conditional Mouse Hover Info Box if (hoveredPlant) { ... }

Shows a small info panel with a plant's age, seed type, light exposure, and alive status when the mouse hovers near its base.

for-loop Timeline Line Charts beginShape(); vertex(0, timelineHeight); ... endShape();

Draws three overlaid line graphs (alive count, cumulative deaths, average fertility) using beginShape()/vertex()/endShape().

background(0, 0, 5); // HSB: hue=0, sat=0, bright=5 (a very dark gray)
Repaints the whole canvas dark gray every frame, erasing the previous frame before redrawing everything fresh - a very common animation pattern.
let brightness = map(noise(x * lightNoiseScale + lightNoiseOffset), 0, 1, lightBrightnessMin, lightBrightnessMax);
Uses 1D Perlin noise sampled along x (plus a slowly drifting offset) to decide how bright/transparent each vertical light band should be.
let currentHue = map(nutrientValue, 0, 100, depletedHue, richHue);
Converts a 0-100 nutrient number into a hue value between the 'depleted' and 'rich' colors defined near the top of the file.
if (dist(mouseX, mouseY, plant.x, plant.y) < plantBaseLength * 2) {
A simple circular hover test - if the mouse is within roughly two branch-lengths of a plant's root, that plant is considered 'hovered'.
const maxCombined = max(maxAlive, maxDead, maxFertility); // Use a common max for better comparison
Finds a single shared maximum so all three timeline curves are scaled to the same vertical range, making them easy to compare visually.
let y = map(aliveHistory[i], 0, maxCombined, timelineHeight, 0); // Scale to timeline height
Maps a data value to a y-coordinate within the timeline strip, flipping it so higher values appear near the top (since y=0 is up).

draw()

draw() is p5.js's main animation loop, called automatically ~60 times per second. This sketch keeps it deliberately tiny by delegating all the real work to updateSimulationLogic() and drawVisuals(), which makes the overall program much easier to read and debug.

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 changing the frame rate.

let simulationSteps = fastForward ? 5 : 1;
Uses a ternary operator to decide how many simulation steps to run this frame - 5 if fast-forward is toggled on, otherwise just 1.
for (let i = 0; i < simulationSteps; i++) { updateSimulationLogic(); }
Calls the update function the chosen number of times, advancing nutrients/seeds/plants further per rendered frame when fast-forwarding.
drawVisuals();
Always draws exactly once per frame, regardless of how many simulation steps happened, keeping the visuals smooth even during fast-forward.

windowResized()

windowResized() is a special p5.js function that fires automatically whenever the browser window changes size. It's a good place to redo any setup logic (like grid dimensions) that depends on width/height.

function windowResized() {
  // Resize the canvas to match the new window dimensions.
  resizeCanvas(windowWidth, windowHeight);

  // Re-initialize the soil grid and wells for the new canvas size.
  initializeSoil();

  // Clear existing seeds and plants for the new canvas size
  seeds = [];
  plants = [];
  plantIDMap.clear();
  totalPlantsAlive = 0;
  totalPlantsDead = 0;
  aliveHistory = [];
  deadHistory = [];
  fertilityHistory = [];
  seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
}
Line-by-line explanation (3 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js function that resizes the actual canvas element whenever the browser window changes size.
initializeSoil();
Rebuilds the soil grid and wells to match the new canvas dimensions - otherwise rows/cols would be based on the old window size.
seeds = []; plants = []; plantIDMap.clear();
Wipes out all existing seeds and plants, since their pixel positions would no longer line up correctly with a resized soil grid.

keyTyped()

keyTyped() is a built-in p5.js event function that automatically runs whenever the user types a key. Using booleans that get flipped with `!variable` is a simple, common way to implement on/off toggles.

function keyTyped() {
  if (key === 'f' || key === 'F') {
    fastForward = !fastForward;
  }
  if (key === 'g' || key === 'G') {
    showGenealogyWeb = !showGenealogyWeb;
  }
}
Line-by-line explanation (2 lines)
if (key === 'f' || key === 'F') { fastForward = !fastForward; }
Checks if the pressed key was 'f' (either case) and flips the fastForward boolean, toggling fast-forward mode on/off.
if (key === 'g' || key === 'G') { showGenealogyWeb = !showGenealogyWeb; }
Toggles whether the genealogy web (lines connecting parent/offspring plants) is drawn.

polygon()

This is a small reusable geometry helper, independent of any specific class, that both the pentagon and hexagon seed shapes call into - a good example of factoring out shared drawing logic instead of duplicating it.

🔬 This is a general-purpose regular polygon drawer. Seeds call it with npoints=5 or 6 - what shape would you get if a seed passed npoints=3 or npoints=20?

  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);
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 Vertex Generation for (let a = 0; a < TWO_PI; a += angle) { ... }

Walks around a full circle in equal angular steps, placing a vertex at each step to form a regular polygon.

let angle = TWO_PI / npoints;
Divides a full circle (TWO_PI radians) into npoints equal slices - so 5 points means 72-degree steps for a pentagon.
let sx = x + cos(a) * radius;
Uses cosine to find the x-coordinate of a point on the circle's edge at angle a.
let sy = y + sin(a) * radius;
Uses sine to find the matching y-coordinate, together plotting a point on the circle.
endShape(CLOSE);
Closes the shape by connecting the last vertex back to the first, completing the polygon outline.

Seed constructor()

This constructor initializes every property a Seed instance needs. Storing state as a string ('falling'/'dormant'/'germinating') is a lightweight way to implement a state machine without needing separate classes for each state.

  constructor(parentPlantID = null) {
    this.x = random(width); // Start at random x position
    this.y = 0; // Start at the top of the canvas
    this.vx = 0; // Initial horizontal velocity
    this.vy = 0; // Initial vertical velocity (gravity)
    this.size = seedSize; // Size of the seed
    this.shape = random(['triangle', 'square', 'pentagon', 'hexagon']); // Random shape
    this.state = 'falling'; // 'falling', 'dormant', 'germinating'
    this.soilCell = null; // Stores [row, col] if dormant or germinating
    this.dormantCheckTimer = 0; // Timer for dormant seeds to recheck nutrients
    this.parentPlantID = parentPlantID; // Store parent ID for genealogy
  }
Line-by-line explanation (3 lines)
this.x = random(width); // Start at random x position
Every new seed appears at a random horizontal position along the top of the canvas.
this.shape = random(['triangle', 'square', 'pentagon', 'hexagon']); // Random shape
Randomly picks one of four shapes from an array - this shape later determines both the seed's look and its L-system growth rules once it becomes a plant.
this.state = 'falling'; // 'falling', 'dormant', 'germinating'
Sets up a simple state machine string that update() and draw() both check to decide what behavior/appearance to use.

Seed.update()

This update() method demonstrates a classic finite state machine pattern with three states ('falling', 'dormant', 'germinating'), where each state has its own distinct behavior and transition rules.

🔬 This decides whether a landed seed germinates instantly or waits dormant. What happens if you lower germinationNutrientThreshold so almost every seed sprouts immediately?

      let nutrientValue = soilGrid[row][col];
        if (nutrientValue >= germinationNutrientThreshold) {
          this.state = 'germinating';
          this.germinate();
        } else {
          this.state = 'dormant';
          this.dormantCheckTimer = dormantRecheckInterval; // Set initial recheck timer
        }
  update() {
    if (this.state === 'falling') {
      // Apply gravity
      this.vy += seedGravity;

      // Apply random wind force
      this.vx += random(-seedWindForce, seedWindForce);
      this.vx = constrain(this.vx, -seedWindForce * 5, seedWindForce * 5); // Limit wind speed

      // Update position
      this.x += this.vx;
      this.y += this.vy;

      // Keep seed within canvas width
      this.x = constrain(this.x, this.size / 2, width - this.size / 2);

      // Check for collision with soil
      const soilStartY = height - soilHeight;
      if (this.y >= soilStartY - this.size / 2) { // Adjusted for seed height
        // Determine which soil cell it landed on
        let col = floor(this.x / cellSize);
        let row = floor((this.y - soilStartY) / cellSize);

        // Ensure it's within the soil grid bounds
        row = constrain(row, 0, rows - 1);
        col = constrain(col, 0, cols - 1);

        this.soilCell = [row, col];

        // Check nutrient level
        let nutrientValue = soilGrid[row][col];
        if (nutrientValue >= germinationNutrientThreshold) {
          this.state = 'germinating';
          this.germinate();
        } else {
          this.state = 'dormant';
          this.dormantCheckTimer = dormantRecheckInterval; // Set initial recheck timer
        }
        // Stop falling
        this.vx = 0;
        this.vy = 0;
        this.y = soilStartY + row * cellSize + cellSize / 2; // Snap to center of cell
      }
    } else if (this.state === 'dormant') {
      // Periodically recheck nutrient level
      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; // Reset timer
      }
    }
    // If state is 'germinating', it will be removed from the array in draw()
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Falling State Physics if (this.state === 'falling') { ... }

Applies gravity and wind to move the seed, then checks for collision with the soil surface.

conditional Dormant Recheck Timer } else if (this.state === 'dormant') { ... }

Periodically rechecks the nutrient level of the cell the seed landed on, germinating once the soil recovers enough fertility.

this.vy += seedGravity;
Continuously accelerates the seed downward, mimicking real gravity by adding to vertical velocity every frame.
this.vx += random(-seedWindForce, seedWindForce);
Adds a tiny random horizontal nudge each frame, simulating gusty wind pushing the seed sideways as it falls.
if (this.y >= soilStartY - this.size / 2) { // Adjusted for seed height
Detects when the falling seed has reached the soil surface, accounting for the seed's own size so it appears to land ON the soil rather than sinking into it.
if (nutrientValue >= germinationNutrientThreshold) { this.state = 'germinating'; this.germinate(); }
If the landing spot is fertile enough, the seed immediately switches to 'germinating' and calls germinate() to spawn a Plant.
this.dormantCheckTimer--;
Counts down a dormant seed's wait timer; once it hits zero the seed rechecks the soil to see if it's now fertile enough to sprout.

Seed.draw()

Using push()/translate()/rotate()/pop() lets each seed draw itself in its own local coordinate system centered at (0,0), then automatically restores the previous drawing state afterward so it doesn't affect anything else.

🔬 Each shape case calls a different drawing function. What happens if you change the hexagon case to call polygon(0, 0, this.size / 2, 3) instead - does it still look hexagonal?

    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;
    }
  draw() {
    push();
    translate(this.x, this.y);
    rotate(frameCount * 0.01); // Subtle rotation for falling seeds

    if (this.state === 'falling') {
      fill(depletedHue + 5, depletedSat + 30, 40); // Slightly brighter brown for falling
    } else if (this.state === 'dormant') {
      fill(0, 0, 30); // Dark gray for dormant seeds
    } else {
      // If germinating, it's about to be removed, so no need to draw.
      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();
  }
Line-by-line explanation (4 lines)

🔧 Subcomponents:

switch-case Shape Drawing Switch switch (this.shape) { case 'triangle': ... }

Chooses which p5.js shape function to call based on the seed's randomly assigned shape string.

translate(this.x, this.y);
Moves the drawing origin to the seed's position, so all shapes drawn afterward are automatically positioned correctly without manual offset math.
rotate(frameCount * 0.01); // Subtle rotation for falling seeds
Continuously rotates the seed a tiny bit more each frame, giving falling seeds a gentle tumbling look.
fill(depletedHue + 5, depletedSat + 30, 40); // Slightly brighter brown for falling
Sets a warm brownish color for falling seeds using the same hue family as depleted soil, just brighter so they stand out.
switch (this.shape) { ... }
Picks the correct p5.js drawing call (triangle, rect, or the custom polygon() helper) that matches the seed's random shape.

Seed.germinate()

This method is the bridge between the Seed and Plant classes - it's the single point in the code where a seed 'becomes' a plant, consuming a resource (nutrients) in exchange for spawning new life.

  germinate() {
    if (this.soilCell) {
      let [row, col] = this.soilCell;
      let nutrientValue = soilGrid[row][col];
      if (nutrientValue >= germinationNutrientThreshold) {
        // Consume nutrients
        soilGrid[row][col] = constrain(nutrientValue - nutrientConsumptionOnGermination, 0, 100);

        // Create a new Plant object and add it to the plants array
        plants.push(new Plant(this.x, this.y, this.soilCell, this.shape, this.parentPlantID)); // Pass parentPlantID
      }
    }
  }
Line-by-line explanation (2 lines)
soilGrid[row][col] = constrain(nutrientValue - nutrientConsumptionOnGermination, 0, 100);
Deducts a fixed nutrient cost from the soil cell as a 'price' for germination, keeping the value clamped between 0-100.
plants.push(new Plant(this.x, this.y, this.soilCell, this.shape, this.parentPlantID));
Creates a brand new Plant object at the seed's position and adds it to the global plants array, passing along the seed's shape and parent ID.

NutrientParticle constructor()

NutrientParticle is a small self-contained particle class representing a visible chunk of nutrients returning to the soil, most commonly created when a plant fully decays via returnNutrients().

  constructor(x, y, nutrientValue) {
    this.x = x + random(-cellSize / 2, cellSize / 2); // Randomize x within cell
    this.y = y;
    this.vy = 0;
    this.nutrientValue = nutrientValue;
    this.size = random(particleSizeMin, particleSizeMax);
    this.state = 'falling'; // 'falling', 'dissolving', 'dissolved'
    this.dissolveTimer = particleLifeSpan; // Timer for dissolving state
  }
Line-by-line explanation (2 lines)
this.x = x + random(-cellSize / 2, cellSize / 2); // Randomize x within cell
Scatters the particle's starting x-position slightly so many particles released at once don't all stack perfectly on top of each other.
this.nutrientValue = nutrientValue;
Stores how much nutrient this particle will return to the soil once it dissolves - passed in from whatever created it (usually a decaying Plant).

NutrientParticle.update()

This mirrors the falling/landing pattern used by Seed.update(), showing how the same basic 'gravity then collide with soil surface' logic gets reused across different classes.

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

      // Check collision with soil
      const soilStartY = height - soilHeight;
      if (this.y >= soilStartY) {
        this.y = soilStartY; // Snap to soil surface
        this.vy = 0;
        this.state = 'dissolving';
        this.dissolve();
      }
    } else if (this.state === 'dissolving') {
      this.dissolveTimer--;
      if (this.dissolveTimer <= 0) {
        this.state = 'dissolved'; // Ready for removal
      }
    }
  }
Line-by-line explanation (3 lines)
this.vy += particleGravity;
Accelerates the particle downward each frame, just like the Seed class does with seedGravity.
if (this.y >= soilStartY) { ... this.state = 'dissolving'; this.dissolve(); }
Once the particle reaches the soil surface, it stops falling, switches state, and immediately calls dissolve() to deposit its nutrients.
this.dissolveTimer--;
Counts down a visual fade-out period after the nutrients have already been added to the soil, purely for the shrinking/fading animation.

NutrientParticle.draw()

Combining a fading alpha value with a shrinking size is a simple but effective way to make disappearing particles feel like they're dissolving rather than just abruptly vanishing.

  draw() {
    if (this.state === 'falling') {
      fill(depletedHue, depletedSat + 10, 20); // Dark brown
      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)); // Grow slightly as it dissolves
    }
  }
Line-by-line explanation (2 lines)
let alpha = map(this.dissolveTimer, 0, particleLifeSpan, 0, 100);
Converts the remaining dissolve timer into a transparency value, so the particle visibly fades out as its timer runs down.
circle(this.x, this.y, this.size * map(this.dissolveTimer, 0, particleLifeSpan, 1.5, 1));
Makes the particle's size shrink slightly over the dissolve period (from 1.5x down to 1x its base size) for a subtle 'soaking in' visual effect.

NutrientParticle.dissolve()

This is the exact opposite operation of nutrient consumption elsewhere in the sketch - it's what closes the loop between plants dying and the soil becoming fertile again.

  dissolve() {
    // Determine which soil cell it landed on
    let col = floor(this.x / cellSize);
    let row = floor((this.y - (height - soilHeight)) / cellSize);

    // Ensure it's within the soil grid bounds
    row = constrain(row, 0, rows - 1);
    col = constrain(col, 0, cols - 1);

    // Add nutrients to the soil
    soilGrid[row][col] = constrain(soilGrid[row][col] + this.nutrientValue, 0, 100);
  }
Line-by-line explanation (2 lines)
let col = floor(this.x / cellSize);
Converts the particle's pixel x-position into a soil grid column index by dividing by the cell size.
soilGrid[row][col] = constrain(soilGrid[row][col] + this.nutrientValue, 0, 100);
Adds the particle's stored nutrient value directly into the soil grid at its landing cell, completing the decay-to-compost nutrient cycle.

NutrientParticle.isDissolved()

Small boolean-returning helper methods like this keep the removal logic in updateSimulationLogic() readable - it can just call particle.isDissolved() instead of comparing strings directly.

  isDissolved() {
    return this.state === 'dissolved';
  }
Line-by-line explanation (1 lines)
return this.state === 'dissolved';
A simple helper that lets updateSimulationLogic() check whether this particle is finished and safe to remove from the array.

Plant constructor()

This constructor sets up a large amount of per-plant state, including L-system data, life/death timers, flowering flags, and genealogy links. Notice how it delegates the actual growth-rule setup to initializeLSystem() and the first branch generation to generateLSystem(), keeping the constructor itself focused on initializing data rather than doing complex work.

  constructor(x, y, soilCell, seedShape, parentPlantID = null) {
    this.id = Plant.nextID++; // Assign unique ID
    plantIDMap.set(this.id, this); // Add to global map

    this.x = x;
    this.y = y;
    this.soilCell = soilCell;
    this.seedShape = seedShape;
    this.plantColor = plantColorMap[seedShape]; // Get color from map
    this.originalPlantColor = { ...this.plantColor }; // Store original color for vibrancy changes

    this.axiom = "F"; // Starting L-system string
    this.rules = {}; // L-system production rules
    this.angle = 0; // Base turning angle (in radians)
    this.length = plantBaseLength; // Base branch length
    this.growthIterations = 0; // Current L-system iterations
    this.maxIterations = plantMaxIterations; // Max L-system iterations
    this.growthTimer = floor(random(plantGrowthInterval / 2, plantGrowthInterval * 1.5)); // Timer for next growth phase
    this.branches = []; // Array to store branch segments
    this.isAlive = true; // State to track if plant is alive
    this.lightExposure = 1.0; // Current light exposure (0-1)
    this.shadowDeathTimer = shadowDeathTimerMax; // Timer until death if in heavy shadow

    this.decayTimer = -1; // -1 when alive, counts down from plantDecayTime when dead
    this.nutrientsReturned = false; // Flag to ensure nutrients are returned only once

    this.flowering = false; // Flag for bloom event
    this.flowerTimer = 0; // Timer for bloom event

    this.birthFrame = frameCount; // Record when the plant germinated

    // Genealogy properties
    this.parentPlantID = parentPlantID;
    this.offspringPlantIDs = []; // Store IDs of offspring plants

    // If a parent ID is provided, link to parent
    if (this.parentPlantID !== null) {
      let parentPlant = plantIDMap.get(this.parentPlantID);
      if (parentPlant) {
        parentPlant.offspringPlantIDs.push(this.id);
      }
    }

    this.initializeLSystem(); // Set L-system rules based on seed shape
    this.generateLSystem(); // Generate initial iteration
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

this.id = Plant.nextID++; // Assign unique ID
Uses a static class counter shared by all Plant instances to guarantee every plant gets a unique, ever-increasing ID number.
this.originalPlantColor = { ...this.plantColor }; // Store original color for vibrancy changes
Copies (rather than references) the base color object so the plant can gradually shift its live plantColor while still remembering its original, undamaged color.
this.growthTimer = floor(random(plantGrowthInterval / 2, plantGrowthInterval * 1.5)); // Timer for next growth phase
Randomizes each plant's first growth delay so plants don't all sprout new branches in perfect unison.
this.initializeLSystem(); // Set L-system rules based on seed shape
Sets up the grammar rules and turning angle that determine this specific plant's branching pattern, based on its seed shape.
this.generateLSystem(); // Generate initial iteration
Runs the L-system once immediately so the plant has at least one visible branch the instant it's created.

Plant.initializeLSystem()

This function shows the 'grammar' half of an L-system: a lookup table of rules that describe how symbols get rewritten. The 'execution' half - actually walking a turtle through the resulting string to draw lines - happens separately in generateLSystem().

🔬 This L-system rule replaces every F with a longer branching pattern. What happens if you increase the angle to radians(60) - do triangle plants look wider or more chaotic?

      case 'triangle':
        this.rules = { "F": "F[+F]F[-F]F" };
        this.angle = radians(25); // Angular branches
        break;
  initializeLSystem() {
    switch (this.seedShape) {
      case 'triangle':
        this.rules = { "F": "F[+F]F[-F]F" };
        this.angle = radians(25); // Angular branches
        break;
      case 'square':
        this.rules = { "F": "F+F-F-F+F" };
        this.angle = radians(90); // Blocky, right-angle turns
        break;
      case 'pentagon':
        this.rules = { "F": "F[+F]F[-F]F" }; // Similar to triangle but with smaller angle for curving
        this.angle = radians(20); // Small angle for spiraling curves
        break;
      case 'hexagon':
        this.rules = { "F": "F[+F]F[-F]F" }; // Similar to triangle but with smaller angle for curving
        this.angle = radians(22.5); // Slightly larger small angle
        break;
      default:
        // Fallback for unknown shapes
        this.rules = { "F": "FF" };
        this.angle = radians(30);
    }
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

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

Assigns a different L-system rewrite rule and turning angle depending on which seed shape spawned this plant, giving each species a visually distinct growth style.

this.rules = { "F": "F[+F]F[-F]F" };
Defines the L-system production rule: every 'F' (draw-forward instruction) gets replaced with this longer string, which draws forward, branches right, draws forward again, branches left, and continues - creating recursive-looking branching.
this.angle = radians(25); // Angular branches
Sets how sharply the turtle turns at each '+' or '-' instruction, converted from degrees to radians since p5.js trig functions expect radians.
this.rules = { "F": "F+F-F-F+F" };
The square shape uses a completely different rule with only right-angle-style turns (paired with a 90-degree angle) to create blocky, geometric growth instead of curvy branching.

Plant.generateLSystem()

This is the heart of the sketch's procedural plant generation. It combines two classic techniques - L-system string rewriting and turtle graphics (walking forward/turning/branching based on a sequence of symbols) - which is exactly how real botanical L-system research visualizes plant growth.

  generateLSystem() {
    if (this.growthIterations >= this.maxIterations) return; // Stop if max iterations reached

    // Generate next L-system string
    let nextAxiom = "";
    for (let i = 0; i < this.axiom.length; i++) {
      let char = this.axiom.charAt(i);
      nextAxiom += this.rules[char] || char; // Apply rule if exists, otherwise keep char
    }
    this.axiom = nextAxiom;
    this.growthIterations++;

    // Turtle graphics to generate branch segments
    this.branches = []; // Clear existing branches before redrawing
    let state = { x: this.x, y: this.y, angle: -HALF_PI }; // Start point at soil, pointing up
    let stack = [];
    let currentLength = this.length * pow(plantLengthDecay, this.growthIterations - 1); // Length decays per iteration

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

          // Influence by light (grow upwards bias)
          // Adjust angle slightly towards -HALF_PI (straight up)
          // Stronger upward bias if in heavy shadow
          let upwardBiasStrength = lerp(0.05, 0.15, 1 - this.lightExposure); // More bias if light exposure is low
          state.angle = lerp(state.angle, -HALF_PI, upwardBiasStrength); // Lerp towards straight up, subtle effect

          // Avoid nearby plants (simple check against other plants' root positions)
          for (let otherPlant of plants) {
            if (otherPlant !== this && otherPlant.branches.length > 0 && otherPlant.isAlive) {
              let otherRoot = otherPlant.branches[0]; // Use root branch as a proxy for plant location
              let d = dist(endX, endY, otherRoot.endX, otherRoot.endY);
              if (d < 50) { // If too close to another plant's root
                let angleToOther = atan2(otherRoot.endY - endY, otherRoot.endX - endX);
                state.angle += radians(map(d, 0, 50, 10, 0)) * (state.angle > angleToOther ? 1 : -1); // Push away
              }
            }
          }

          // Randomness
          state.angle += random(-radians(5), radians(5)); // Slight random wobble

          // Store the branch segment
          this.branches.push({
            startX: state.x,
            startY: state.y,
            endX: endX,
            endY: endY,
            angle: state.angle, // Store current angle for drawing
            originalAngle: state.angle, // Store original angle for decay reference
            length: currentLength,
            depth: this.growthIterations,
            lightExposure: 1.0 // Will be calculated globally each frame
          });
          state.x = endX;
          state.y = endY;
          break;
        case '+': // Turn right
          state.angle += this.angle;
          break;
        case '-': // Turn left
          state.angle -= this.angle;
          break;
        case '[': // Push state (position and angle)
          stack.push({ x: state.x, y: state.y, angle: state.angle });
          break;
        case ']': // Pop state
          state = stack.pop();
          break;
      }
    }
  }
Line-by-line explanation (7 lines)

🔧 Subcomponents:

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

Rewrites the plant's instruction string by replacing every character with its rule (if one exists), one L-system generation at a time.

for-loop Turtle Graphics Walk for (let i = 0; i < this.axiom.length; i++) { let char = this.axiom.charAt(i); switch (char) { ... } }

Walks a virtual 'turtle' through the rewritten instruction string, turning it into actual branch line segments with position and angle.

for-loop Neighbor Avoidance for (let otherPlant of plants) { ... if (d < 50) { ... } }

Checks each new branch tip against every other living plant's root and nudges its growth angle away if it's growing too close to a neighbor.

if (this.growthIterations >= this.maxIterations) return; // Stop if max iterations reached
An early-return guard clause that prevents the plant from growing past its configured maximum complexity.
nextAxiom += this.rules[char] || char; // Apply rule if exists, otherwise keep char
For every character in the current string, looks up a replacement rule; if none exists (like for '+' or '['), the character is kept as-is.
let currentLength = this.length * pow(plantLengthDecay, this.growthIterations - 1); // Length decays per iteration
Shrinks branch length exponentially with each growth generation, so later, smaller branches look like natural twigs rather than full-size limbs.
state.angle = lerp(state.angle, -HALF_PI, upwardBiasStrength); // Lerp towards straight up, subtle effect
Gently steers each new branch segment toward pointing straight up, simulating phototropism (plants growing toward light), with a stronger pull when the plant is in shadow.
if (d < 50) { ... state.angle += radians(map(d, 0, 50, 10, 0)) * (state.angle > angleToOther ? 1 : -1); }
If a branch tip is growing within 50 pixels of another plant's root, it steers the growth angle away from that neighbor, closer neighbors causing a stronger push.
case '[': stack.push({ x: state.x, y: state.y, angle: state.angle }); break;
Implements the classic turtle-graphics 'save state' operation - pushing the current position/angle onto a stack so a branch can split off and later return to this point.
case ']': state = stack.pop(); break;
Restores the most recently saved position/angle, which is how the turtle 'jumps back' to draw a second branch from the same fork point.

Plant.update()

This is the plant's entire life-simulation logic in one place: eating nutrients, deciding when to grow, dying from shade or starvation, smoothly animating color/health changes, and handling the wilting decay animation - a good example of a single update() method managing several independent but interacting rules.

🔬 This is the shade-death rule. What happens if you remove the 'else' reset so shadowDeathTimer only ever counts down and never recovers, even briefly stepping into the sun?

      if (this.lightExposure < shadowDeathThreshold) {
        this.shadowDeathTimer--;
        if (this.shadowDeathTimer <= 0) {
          this.isAlive = false; // Plant dies from lack of light
          this.decayTimer = plantDecayTime; // Start decay
        }
      } else {
        this.shadowDeathTimer = shadowDeathTimerMax; // Reset timer if light improves
      }
  update() {
    if (this.isAlive) {
      // Nutrient Consumption
      let [row, col] = this.soilCell;
      let nutrientValue = soilGrid[row][col];

      // Consumption increases with plant complexity (growth iterations)
      let consumptionAmount = plantNutrientConsumptionBase * (1 + this.growthIterations * plantNutrientConsumptionMultiplier);
      soilGrid[row][col] = constrain(nutrientValue - consumptionAmount, 0, 100);

      // Growth
      // Plants grow based on light exposure and nutrients
      if (this.growthIterations < this.maxIterations) {
        if (nutrientValue > germinationNutrientThreshold / 2) { // Only grow if soil has some nutrients
          let currentGrowthInterval = plantGrowthInterval;

          // Influence growth interval based on light exposure
          if (this.lightExposure < shadowThreshold) {
            // Grow slower in shadow, or stop if light is too low
            currentGrowthInterval = lerp(plantGrowthInterval, plantGrowthInterval * 5, 1 - this.lightExposure / shadowThreshold);
            currentGrowthInterval = constrain(currentGrowthInterval, plantGrowthInterval, plantGrowthInterval * 5);
          } else {
            // Grow faster in good light
            currentGrowthInterval = lerp(plantGrowthInterval, plantGrowthInterval * 0.7, (this.lightExposure - shadowThreshold) / (1 - shadowThreshold));
            currentGrowthInterval = constrain(currentGrowthInterval, plantGrowthInterval * 0.7, plantGrowthInterval);
          }

          this.growthTimer--;
          if (this.growthTimer <= 0) {
            // Only generate new branches if light exposure is above a minimal threshold
            if (this.lightExposure > shadowDeathThreshold) {
              this.generateLSystem();
              this.growthTimer = floor(random(currentGrowthInterval / 2, currentGrowthInterval * 1.5));
            } else {
              // If light is too low, don't grow, just reset timer slowly
              this.growthTimer = floor(random(currentGrowthInterval * 2, currentGrowthInterval * 3));
            }
          }
        } else {
          this.growthTimer = plantGrowthInterval * 2; // Slow down growth check if nutrients are low
        }
      }

      // Light-based death
      if (this.lightExposure < shadowDeathThreshold) {
        this.shadowDeathTimer--;
        if (this.shadowDeathTimer <= 0) {
          this.isAlive = false; // Plant dies from lack of light
          this.decayTimer = plantDecayTime; // Start decay
        }
      } else {
        this.shadowDeathTimer = shadowDeathTimerMax; // Reset timer if light improves
      }

      // Nutrient-based death (original logic)
      if (nutrientValue < germinationNutrientThreshold / 4 && this.growthIterations > 0) {
        this.isAlive = false;
        this.decayTimer = plantDecayTime; // Start decay
      }

      // Adjust color vibrancy and brightness based on light exposure
      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); // Smooth transition
      this.plantColor.sat = lerp(this.plantColor.sat, targetSaturation, 0.1); // Smooth transition

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

    } else { // Plant is not alive, it's decaying
      if (this.decayTimer > 0) {
        this.decayTimer--;
        let decayProgress = 1 - (this.decayTimer / plantDecayTime); // 0 at start of decay, 1 at end

        // Branches droop (lerp angle from original to HALF_PI (down))
        for (let branch of this.branches) {
          branch.angle = lerp(branch.originalAngle, HALF_PI, decayProgress * decayProgress); // Use easing for smoother droop
        }

        // Colors fade to gray/brown, then to black
        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); // Fade to very dark

        // Return nutrients when decay is complete
        if (this.decayTimer <= 0 && !this.nutrientsReturned) {
          this.returnNutrients();
          this.nutrientsReturned = true;
          totalPlantsDead++; // Increment cumulative dead plants count
        }
      }
    }
  }
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Nutrient Consumption let consumptionAmount = plantNutrientConsumptionBase * (1 + this.growthIterations * plantNutrientConsumptionMultiplier);

Calculates how much soil nutrient the plant eats this frame, scaling up as the plant grows more complex (more iterations).

conditional Light-Influenced Growth Timer if (this.lightExposure < shadowThreshold) { currentGrowthInterval = lerp(...); } else { currentGrowthInterval = lerp(...); }

Speeds up or slows down how quickly the plant advances its L-system growth based on how much light it's currently receiving.

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

Kills the plant if it stays in deep shadow for too many consecutive frames.

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

Gradually rotates every branch's drawing angle toward straight down as the plant decays, creating a visible wilting effect.

soilGrid[row][col] = constrain(nutrientValue - consumptionAmount, 0, 100);
Removes the calculated consumption amount from the plant's home soil cell every single frame it's alive - this is how plants deplete their own soil over time.
currentGrowthInterval = lerp(plantGrowthInterval, plantGrowthInterval * 5, 1 - this.lightExposure / shadowThreshold);
In shadow, stretches the wait time between growth spurts up to 5x longer, smoothly scaled by how far below the shadow threshold the plant's light exposure is.
if (this.lightExposure > shadowDeathThreshold) { this.generateLSystem(); ... }
Even if the growth timer runs out, the plant only actually grows a new branch generation if its light exposure is above the death threshold - otherwise it just resets its timer without growing.
if (nutrientValue < germinationNutrientThreshold / 4 && this.growthIterations > 0) { this.isAlive = false; ... }
A second, independent death condition: if the soil under a plant becomes severely depleted, the plant dies regardless of how much light it's getting.
let decayProgress = 1 - (this.decayTimer / plantDecayTime); // 0 at start of decay, 1 at end
Converts the countdown decayTimer into a normalized 0-to-1 progress value that's easy to feed into lerp() for smooth animated transitions.
if (this.decayTimer <= 0 && !this.nutrientsReturned) { this.returnNutrients(); ... }
The nutrientsReturned flag guarantees returnNutrients() only ever fires once per plant, even though this update() method keeps running every frame while decayTimer sits at 0 waiting to be removed.

Plant.draw()

Notice this method re-runs the turtle-graphics walk from scratch every frame instead of storing pre-computed line coordinates. That's a deliberate trade-off: it's slightly more computation per frame, but it lets branch angles keep animating smoothly (like the decay droop) without needing to regenerate the whole L-system string.

🔬 This draws a small circle for the flower. What happens if you multiply plantBranchThickness by a much bigger number here, making flowers huge compared to the branches?

        circle(0, -plantBranchThickness * 2, plantBranchThickness * 3); // Small circle at tip
  draw() {
    if (!this.isAlive && this.decayTimer <= 0) return; // Don't draw if fully decayed

    // Set color based on seed shape and light exposure (or decay state)
    strokeWeight(plantBranchThickness);
    stroke(this.plantColor.hue, this.plantColor.sat, this.plantColor.bright);

    // Re-draw branches using their current (potentially decayed) angles
    let state = { x: this.x, y: this.y, angle: -HALF_PI }; // Start point at soil, pointing up
    let stack = [];
    let currentLength = plantBaseLength * pow(plantLengthDecay, this.growthIterations - 1); // Length decays per iteration

    for (let char of this.axiom) {
      switch (char) {
        case 'F': // Draw forward
          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 '+': // Turn right
          state.angle += this.angle;
          break;
        case '-': // Turn left
          state.angle -= this.angle;
          break;
        case '[': // Push state (position and angle)
          stack.push({ x: state.x, y: state.y, angle: state.angle });
          break;
        case ']': // Pop state
          state = stack.pop();
          break;
      }
    }

    // Draw flowers if blooming
    if (this.flowering && this.flowerTimer > 0) {
      push();
      // Find the highest point (lowest y) of the plant
      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); // Rotate to be upright
        noStroke();
        fill(60, 80, 100, map(this.flowerTimer, 0, bloomDuration, 0, 100)); // Bright yellow, fade out
        circle(0, -plantBranchThickness * 2, plantBranchThickness * 3); // Small circle at tip
        // Optional: Draw a small star or other shape
        // triangle(-plantBranchThickness, -plantBranchThickness * 2, 0, -plantBranchThickness * 4, plantBranchThickness, -plantBranchThickness * 2);
      }
      pop();
    }
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Turtle Graphics Redraw for (let char of this.axiom) { switch (char) { case 'F': ... line(state.x, state.y, endX, endY); ... } }

Re-walks the plant's stored L-system string every single frame to draw its current branch lines, using whatever angles update() has set (including drooping decay angles).

if (!this.isAlive && this.decayTimer <= 0) return; // Don't draw if fully decayed
A guard clause that skips drawing entirely once a plant has finished decaying, since it's about to be removed from the array anyway.
stroke(this.plantColor.hue, this.plantColor.sat, this.plantColor.bright);
Uses the plant's live, possibly-fading color object (updated each frame in update()) to set the branch line color.
let currentLength = plantBaseLength * pow(plantLengthDecay, this.growthIterations - 1); // Length decays per iteration
Recomputes the same length-decay formula used in generateLSystem() so the redrawn branches match the lengths that were originally generated.
if (branch.endY < highestY) { highestY = branch.endY; highestBranchTip = { x: branch.endX, y: branch.endY, angle: branch.angle }; }
Since smaller y-values are higher on screen, this keeps track of whichever branch tip currently has the smallest endY, i.e. the visually topmost point.
fill(60, 80, 100, map(this.flowerTimer, 0, bloomDuration, 0, 100)); // Bright yellow, fade out
Draws the flower in a bright yellow hue whose transparency is tied to the remaining flowerTimer, so flowers visibly fade away as the bloom event ends.

Plant.returnNutrients()

This method is only ever called once, from update(), right when a plant finishes fully decaying - it's the key mechanic that keeps the ecosystem balanced instead of simply depleting the soil forever.

  returnNutrients() {
    let [row, col] = this.soilCell;
    // Calculate nutrients consumed over its lifetime, with compost bonus
    let nutrientsToReturn = plantNutrientConsumptionBase * (1 + this.growthIterations * plantNutrientConsumptionMultiplier) * plantCompostBonus * plantGrowthInterval * this.growthIterations;

    // Distribute nutrients as particles
    const numParticles = floor(nutrientsToReturn / 5) + 1; // At least one particle
    const nutrientsPerParticle = nutrientsToReturn / numParticles;

    for (let i = 0; i < numParticles; i++) {
      nutrientParticles.push(new NutrientParticle(this.x, this.y, nutrientsPerParticle));
    }
  }
Line-by-line explanation (3 lines)

🔧 Subcomponents:

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

Splits a plant's total returned nutrients into several smaller NutrientParticle objects rather than one giant nutrient blob.

let nutrientsToReturn = plantNutrientConsumptionBase * (1 + this.growthIterations * plantNutrientConsumptionMultiplier) * plantCompostBonus * plantGrowthInterval * this.growthIterations;
Estimates roughly how many nutrients the plant consumed across its whole life, then multiplies by a 'compost bonus' so decaying plants actually enrich the soil more than they took, encouraging renewal.
const numParticles = floor(nutrientsToReturn / 5) + 1; // At least one particle
Decides how many visible particles to spawn based on the total amount, guaranteeing at least one particle even for small plants.
nutrientParticles.push(new NutrientParticle(this.x, this.y, nutrientsPerParticle));
Creates each particle at the plant's root position, carrying an equal share of the total nutrients to be released.

Plant.isDead()

This tiny helper method keeps updateSimulationLogic() readable by hiding the two-condition check behind a clearly-named function call, following the same pattern as NutrientParticle.isDissolved().

  isDead() {
    return !this.isAlive && this.decayTimer <= 0;
  }
Line-by-line explanation (1 lines)
return !this.isAlive && this.decayTimer <= 0;
A plant only counts as fully 'dead' (safe to remove from the array) once it's both marked not-alive AND finished its entire decay countdown.

📦 Key Variables

cellSize number

The pixel width/height of a single soil grid cell; determines grid resolution.

const cellSize = 10;
soilHeight number

How many pixels tall the soil strip at the bottom of the canvas is.

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 value from 0-100; the core data structure of the simulation.

let soilGrid;
noiseOffset number

A slowly-incrementing offset fed into Perlin noise to make the soil's noise pattern drift over time.

let noiseOffset = 0;
noiseScale number

Controls how 'chunky' or fine the Perlin noise pattern looks across the grid.

const noiseScale = 0.05;
noiseDriftRate number

How fast noiseOffset increases each frame, controlling the speed of the soil's 'breathing' effect.

const noiseDriftRate = 0.0005;
noiseNutrientInfluence number

The maximum amount noise can nudge a cell's nutrient value up or down each frame.

const noiseNutrientInfluence = 2;
nutrientWells array

List of well objects ({x, radius}) that continually regenerate nearby soil fertility.

let nutrientWells = [];
minWells number

The minimum number of nutrient wells created at setup/resize.

const minWells = 5;
maxWells number

The maximum number of nutrient wells created at setup/resize.

const maxWells = 8;
wellRadius number

How far (in pixels) each well's fertility boost reaches.

const wellRadius = 70;
wellRegenRate number

How much nutrient a well adds per frame at its very center.

const wellRegenRate = 0.15;
diffusionRate number

The fraction of a cell's nutrient value that blends with its neighbors' average each frame.

const diffusionRate = 0.03;
seeds array

List of all active Seed objects currently falling, dormant, or about to germinate.

let seeds = [];
seedSpawnTimer number

Countdown of frames until the next normal (non-rain) seed spawns.

let seedSpawnTimer = 0;
seedSpawnIntervalMin number

The shortest possible wait time between normal seed spawns.

const seedSpawnIntervalMin = 60;
seedSpawnIntervalMax number

The longest possible wait time between normal seed spawns.

const seedSpawnIntervalMax = 120;
seedSize number

The 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

The maximum random horizontal push applied to falling seeds, simulating wind.

const seedWindForce = 0.05;
germinationNutrientThreshold number

The minimum soil nutrient level required for a seed to germinate.

const germinationNutrientThreshold = 40;
nutrientConsumptionOnGermination number

How many nutrients are subtracted from the soil the moment a seed germinates.

const nutrientConsumptionOnGermination = 30;
dormantRecheckInterval number

How many frames a dormant seed waits before rechecking whether the soil has become fertile enough.

const dormantRecheckInterval = 60;
nutrientParticles array

List of active NutrientParticle objects visualizing nutrients returning to 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

How many frames a dissolving particle stays visible before disappearing entirely.

const particleLifeSpan = 120;
seedRainActive boolean

Whether a seed rain event is currently in progress.

let seedRainActive = false;
seedRainTimer number

Countdown of remaining seeds to drop during the current seed rain event.

let seedRainTimer = 0;
seedRainCount number

How many seeds a single seed rain event spawns.

const seedRainCount = 20;
seedRainInterval number

How many frames pass between the start of each seed rain event.

const seedRainInterval = 1000;
droughtActive boolean

Whether a drought event is currently suppressing well regeneration and other events.

let droughtActive = false;
droughtTimer number

Countdown of remaining frames in the current drought.

let droughtTimer = 0;
droughtDuration number

How long, in frames, a drought event lasts.

const droughtDuration = 300;
bloomActive boolean

Whether a bloom (flowering) event is currently active.

let bloomActive = false;
bloomTimer number

Countdown of remaining frames in the current bloom event.

let bloomTimer = 0;
bloomDuration number

How long, in frames, a bloom event (and each plant's flowering) lasts.

const bloomDuration = 180;
bloomInterval number

How many frames pass between the start of each bloom event.

const bloomInterval = 2000;
plants array

List of all active Plant objects, alive or currently decaying.

let plants = [];
plantIDMap object

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

let plantIDMap = new Map();
totalPlantsAlive number

Current count of living plants, recalculated every frame and fed into the timeline.

let totalPlantsAlive = 0;
totalPlantsDead number

Cumulative count of plants that have fully decayed since the sketch started.

let totalPlantsDead = 0;
plantGrowthInterval number

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

const plantGrowthInterval = 30;
plantMaxIterations number

The maximum number of L-system rewrite generations a plant can reach.

const plantMaxIterations = 4;
plantBaseLength number

The starting branch segment length before decay is applied.

const plantBaseLength = 10;
plantLengthDecay number

Multiplier applied to branch length each growth iteration, shrinking later branches.

const plantLengthDecay = 0.7;
plantBranchThickness number

The stroke weight used when drawing plant branch lines.

const plantBranchThickness = 1.5;
plantNutrientConsumptionBase number

The base amount of nutrients a plant consumes per frame before scaling by complexity.

const plantNutrientConsumptionBase = 0.05;
plantNutrientConsumptionMultiplier number

Multiplier that increases nutrient consumption as a plant grows more complex.

const plantNutrientConsumptionMultiplier = 0.5;
plantDecayTime number

How many frames a dead plant takes to fully decay and disappear.

const plantDecayTime = 200;
plantCompostBonus number

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

const plantCompostBonus = 1.5;
lightNoiseOffset number

A slowly-incrementing offset used to drift the light band pattern over time.

let lightNoiseOffset = 0;
lightNoiseScale number

Controls the width/pattern of the drifting light bands.

const lightNoiseScale = 0.005;
lightDriftRate number

How fast the light bands drift across the scene each frame.

const lightDriftRate = 0.0001;
lightBrightnessMin number

The minimum transparency/brightness value used for light bands.

const lightBrightnessMin = 0;
lightBrightnessMax number

The maximum transparency/brightness value used for light bands.

const lightBrightnessMax = 10;
shadowThreshold number

The light exposure level below which plants start growing more slowly.

const shadowThreshold = 0.6;
shadowDeathThreshold number

The light exposure level below which plants stop growing entirely and begin risking death.

const shadowDeathThreshold = 0.3;
shadowDeathTimerMax number

How many consecutive frames a plant can survive below shadowDeathThreshold before dying.

const shadowDeathTimerMax = 180;
aliveHistory array

Rolling history of totalPlantsAlive values used to draw the timeline's green line.

let aliveHistory = [];
deadHistory array

Rolling history of totalPlantsDead values used to draw the timeline's gray line.

let deadHistory = [];
fertilityHistory array

Rolling history of average soil fertility used to draw the timeline's amber line.

let fertilityHistory = [];
historyLength number

The maximum number of frames of history kept for the timeline charts.

const historyLength = 200;
timelineHeight number

The pixel height of the timeline bar drawn at the bottom of the canvas.

const timelineHeight = 60;
showGenealogyWeb boolean

Toggle (via the 'G' key) for whether parent-offspring lines are drawn between plants.

let showGenealogyWeb = false;
fastForward boolean

Toggle (via the 'F' key) that runs the simulation update multiple times per rendered frame.

let fastForward = false;
depletedHue number

The HSB hue used for the most nutrient-depleted soil color.

const depletedHue = 30;
depletedSat number

The HSB saturation used for the most nutrient-depleted soil color.

const depletedSat = 20;
depletedBright number

The HSB brightness used for the most nutrient-depleted soil color.

const depletedBright = 10;
richHue number

The HSB hue used for the most nutrient-rich soil color.

const richHue = 15;
richSat number

The HSB saturation used for the most nutrient-rich soil color.

const richSat = 95;
richBright number

The HSB brightness used for the most nutrient-rich soil color.

const richBright = 85;
plantColorMap object

Lookup table mapping each seed shape to its base plant color (hue/saturation/brightness).

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

🔧 Potential Improvements (5)

Here are some ways this code could be enhanced:

BUG Sketch description vs. code (missing mousePressed)

The sketch's description says you can 'Click ... to trigger events', but there is no mousePressed() function anywhere in sketch.js - clicking the canvas currently does nothing.

💡 Add a mousePressed() function that manually triggers seedRainActive, droughtActive, or bloomActive (reusing the same logic already in updateSimulationLogic()) so the advertised click interaction actually works.

PERFORMANCE updateSimulationLogic() - Nutrient Well Regeneration

For every well, the code loops over the ENTIRE soil grid (rows * cols cells) just to find the small subset of cells within wellRadius, which wastes a lot of computation, especially with maxWells=8 wells and a large canvas.

💡 Precompute the row/column range that could possibly be within wellRadius of the well's x position and only loop over that smaller rectangle of cells, skipping the full-grid scan.

PERFORMANCE updateSimulationLogic() - Shadow Calculation

The plant-avoidance check inside generateLSystem() and the shadow-casting sort both loop over the full `plants` array for every branch/plant, which can become slow (O(n^2) or worse) as many plants accumulate on screen.

💡 Consider spatial partitioning (like a simple grid bucket keyed by x-position) so nearby-plant checks only compare against plants in the same or adjacent buckets instead of the entire array.

STYLE Plant.generateLSystem() and Plant.draw()

Both methods duplicate the same turtle-graphics walking logic (the switch on 'F'/'+'/'-'/'['/']') almost line-for-line, which makes future changes error-prone since a fix in one place is easy to forget in the other.

💡 Extract a shared helper like walkLSystem(axiom, angleStep, startAngle, onForward) that both methods call, passing in a callback for what to do on each 'F' step (store a branch object vs. draw a line).

FEATURE windowResized()

Resizing the browser window completely wipes all existing seeds and plants, which can feel abrupt and destroy an interesting, long-grown garden just because the user resized their browser.

💡 Instead of clearing everything, try rescaling existing plant/seed x-positions proportionally to the new width (e.g., plant.x *= newWidth / oldWidth) so the garden survives a resize.

🔄 Code Flow

Code flow showing setup, initializesoil, updatesimulationlogic, drawvisuals, draw, windowresized, keytyped, polygon, seedconstructor, seedupdate, seeddraw, seedgerminate, nutrientparticleconstructor, nutrientparticleupdate, nutrientparticledraw, nutrientparticledissolve, nutrientparticleisdissolved, plantconstructor, plantinitializelsystem, plantgeneratelsystem, plantupdate, plantdraw, plantreturnnutrients, plantisdead

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

graph TD start[Start] --> setup[setup] setup --> initializesoil[initializesoil] setup --> draw[draw loop] draw --> updatesimulationlogic[updatesimulationlogic] draw --> drawvisuals[drawvisuals] updatesimulationlogic --> gridbuild[grid-build] updatesimulationlogic --> wellloop[well-loop] updatesimulationlogic --> eventtriggers[event-triggers] updatesimulationlogic --> noisedrift[noise-drift] updatesimulationlogic --> wellregen[well-regen] updatesimulationlogic --> diffusionstep[diffusion-step] updatesimulationlogic --> shadowloop[shadow-loop] updatesimulationlogic --> seedplantparticleupdates[seed-plant-particle-updates] drawvisuals --> lightbands[light-bands] drawvisuals --> soilrender[soil-render] drawvisuals --> genealogylines[genealogy-lines] drawvisuals --> hovertooltip[hover-tooltip] drawvisuals --> timelinecharts[timeline-charts] draw --> fastforwardloop[fast-forward-loop] click setup href "#fn-setup" click initializesoil href "#fn-initializesoil" click draw href "#fn-draw" click updatesimulationlogic href "#fn-updatesimulationlogic" click drawvisuals href "#fn-drawvisuals" click gridbuild href "#sub-grid-build" click wellloop href "#sub-well-loop" click eventtriggers href "#sub-event-triggers" click noisedrift href "#sub-noise-drift" click wellregen href "#sub-well-regen" click diffusionstep href "#sub-diffusion-step" click shadowloop href "#sub-shadow-loop" click seedplantparticleupdates href "#sub-seed-plant-particle-updates" click lightbands href "#sub-light-bands" click soilrender href "#sub-soil-render" click genealogylines href "#sub-genealogy-lines" click hovertooltip href "#sub-hover-tooltip" click timelinecharts href "#sub-timeline-charts" click fastforwardloop href "#sub-fast-forward-loop"

❓ Frequently Asked Questions

What visual experience does the FRACTAL GARDEN2 sketch offer?

The FRACTAL GARDEN2 sketch creates a dynamic and visually captivating cross-section of soil, showcasing drifting nutrients and the growth of branching plants in real time.

How can users interact with the FRACTAL GARDEN2 sketch?

Users can interact with the sketch by clicking or allowing it to run automatically, triggering events like seed showers and droughts that affect the underground ecosystem.

What creative coding concepts are demonstrated in the FRACTAL GARDEN2 sketch?

This sketch demonstrates L-system-inspired logic for plant growth and utilizes Perlin noise to simulate organic soil breathing and nutrient diffusion.

Preview

FRACTAL GARDEN2 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of FRACTAL GARDEN2 - Code flow showing setup, initializesoil, updatesimulationlogic, drawvisuals, draw, windowresized, keytyped, polygon, seedconstructor, seedupdate, seeddraw, seedgerminate, nutrientparticleconstructor, nutrientparticleupdate, nutrientparticledraw, nutrientparticledissolve, nutrientparticleisdissolved, plantconstructor, plantinitializelsystem, plantgeneratelsystem, plantupdate, plantdraw, plantreturnnutrients, plantisdead
Code Flow Diagram