FRACTAL GARDEN2

This sketch simulates a living underground ecosystem where seeds fall, germinate, and grow into branching plants using L-system rules. The soil breathes through Perlin noise drift, nutrients diffuse between cells, nutrient wells regenerate resources, and plants compete for light and nutrients while responding to environmental events like seed showers, droughts, and blooms.

๐ŸŽ“ Concepts You'll Learn

L-system fractalsPerlin noise2D grid simulationNutrient diffusionLight shadow castingParticle systemsObject-oriented programming with classesGenealogy trackingHSB color modeTurtle graphicsEvent-driven simulationTimeline visualization

๐Ÿ”„ Code Flow

Code flow showing setup, initializesoil, updatesimulationlogic, drawvisuals, draw, windowresized, keytyped, polygon

๐Ÿ’ก Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> initializesoil[initializesoil] initializesoil --> grid-dimensions[grid-dimensions] grid-dimensions --> grid-initialization[grid-initialization] grid-initialization --> wells-creation[wells-creation] wells-creation --> updatesimulationlogic[updatesimulationlogic] updatesimulationlogic --> fast-forward-check[fast-forward-check] fast-forward-check --> simulation-loop[simulation-loop] simulation-loop --> event-seed-rain[event-seed-rain] simulation-loop --> event-drought[event-drought] simulation-loop --> event-bloom[event-bloom] simulation-loop --> perlin-noise-drift[perlin-noise-drift] simulation-loop --> well-regeneration[well-regeneration] simulation-loop --> nutrient-diffusion[nutrient-diffusion] simulation-loop --> shadow-calculation[shadow-calculation] simulation-loop --> seed-spawning[seed-spawning] updatesimulationlogic --> draw[draw loop] draw --> background-clear[background-clear] background-clear --> light-layer[light-layer] light-layer --> soil-drawing[soil-drawing] soil-drawing --> hover-detection[hover-detection] hover-detection --> timeline-update[timeline-update] timeline-update --> timeline-drawing[timeline-drawing] draw --> drawvisuals[drawvisuals] drawvisuals --> draw click setup href "#fn-setup" click initializesoil href "#fn-initializesoil" click updatesimulationlogic href "#fn-updatesimulationlogic" click draw href "#fn-draw" click drawvisuals href "#fn-drawvisuals" click grid-dimensions href "#sub-grid-dimensions" click grid-initialization href "#sub-grid-initialization" click wells-creation href "#sub-wells-creation" click fast-forward-check href "#sub-fast-forward-check" click simulation-loop href "#sub-simulation-loop" click event-seed-rain href "#sub-event-seed-rain" click event-drought href "#sub-event-drought" click event-bloom href "#sub-event-bloom" click perlin-noise-drift href "#sub-perlin-noise-drift" click well-regeneration href "#sub-well-regeneration" click nutrient-diffusion href "#sub-nutrient-diffusion" click shadow-calculation href "#sub-shadow-calculation" click seed-spawning href "#sub-seed-spawning" click background-clear href "#sub-background-clear" click light-layer href "#sub-light-layer" click soil-drawing href "#sub-soil-drawing" click hover-detection href "#sub-hover-detection" click timeline-update href "#sub-timeline-update" click timeline-drawing href "#sub-timeline-drawing"

๐Ÿ“ Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes the canvas, color mode, and soil system. The HSB color mode is crucial for this sketch because it allows intuitive color transitions from brown (depleted soil) to amber (rich soil).

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100);
  noStroke();
  initializeSoil();
  seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
}

Line by Line:

createCanvas(windowWidth, windowHeight)
Creates a canvas that fills the entire browser window, allowing the simulation to use all available space
colorMode(HSB, 360, 100, 100)
Switches from RGB to HSB color mode where Hue ranges 0-360 (color wheel), Saturation 0-100 (purity), and Brightness 0-100 (lightness). This makes it easier to create color gradients for soil nutrients
noStroke()
Disables outlines around shapes by default (though individual functions can override this with stroke())
initializeSoil()
Calls the function that creates the soil grid and nutrient wells
seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1))
Sets a random timer for when the first seed should spawn, creating variation in spawn timing

initializeSoil()

This function sets up the soil grid as a 2D array where each cell represents a 10x10 pixel area containing a nutrient value. Perlin noise creates natural-looking variation rather than random noise. Nutrient wells are placed randomly and will regenerate nutrients in their surrounding areas, creating oases of fertility in the soil.

function initializeSoil() {
  rows = floor(soilHeight / cellSize);
  cols = floor(width / cellSize);
  soilGrid = Array(rows).fill(0).map((_, i) => Array(cols).fill(0).map((_, j) => {
    let initialNutrient = 50 + map(noise(j * 0.1, i * 0.1), 0, 1, -20, 20);
    return constrain(initialNutrient, 0, 100);
  }));
  nutrientWells = [];
  let numWells = floor(random(minWells, maxWells + 1));
  for (let k = 0; k < numWells; k++) {
    nutrientWells.push({
      x: random(width),
      radius: wellRadius
    });
  }
}

๐Ÿ”ง Subcomponents:

calculation Calculate Grid Dimensions rows = floor(soilHeight / cellSize); cols = floor(width / cellSize);

Determines how many rows and columns the soil grid needs based on canvas size and cell size

for-loop Initialize Soil Grid with Perlin Noise soilGrid = Array(rows).fill(0).map((_, i) => Array(cols).fill(0).map((_, j) => {...}));

Creates a 2D array where each cell stores a nutrient value (0-100), initialized with Perlin noise for natural variation

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

Creates random nutrient wells that regenerate soil nutrients in their radius of influence

Line by Line:

rows = floor(soilHeight / cellSize)
Calculates how many rows fit in the soil area by dividing soil height by cell size
cols = floor(width / cellSize)
Calculates how many columns fit across the canvas width by dividing width by cell size
soilGrid = Array(rows).fill(0).map((_, i) => Array(cols).fill(0).map((_, j) => {...}))
Creates a 2D array (grid) with rows and columns. The nested map functions create each row and then each cell within that row
let initialNutrient = 50 + map(noise(j * 0.1, i * 0.1), 0, 1, -20, 20)
Each cell starts with a base nutrient value of 50, plus a random variation from Perlin noise. The noise creates natural-looking patches of rich and depleted soil
return constrain(initialNutrient, 0, 100)
Ensures nutrient values stay within the valid 0-100 range, preventing invalid values
let numWells = floor(random(minWells, maxWells + 1))
Randomly chooses how many nutrient wells to create (between minWells and maxWells)
nutrientWells.push({ x: random(width), radius: wellRadius })
Adds a new well object with a random x-position and a fixed radius of influence

updateSimulationLogic()

This is the core simulation logic that runs every frame. It handles all the environmental systems: events (seed rain, drought, bloom), nutrient dynamics (Perlin noise breathing, well regeneration, diffusion), light calculations (shadow casting), and object updates (seeds, plants, particles). The function is called multiple times per frame when fast-forward is enabled.

function updateSimulationLogic() {
  const soilStartY = height - soilHeight;
  // === Event Management ===
  if (frameCount % seedRainInterval === 0 && !droughtActive) {
    seedRainActive = true;
    seedRainTimer = seedRainCount;
  }
  if (seedRainActive && seedRainTimer > 0) {
    seeds.push(new Seed());
    seedRainTimer--;
    if (seedRainTimer <= 0) seedRainActive = false;
  }
  if (frameCount % (seedRainInterval + 500) === 0 && !droughtActive) {
    droughtActive = true;
    droughtTimer = droughtDuration;
  }
  if (droughtActive) {
    droughtTimer--;
    if (droughtTimer <= 0) droughtActive = false;
  }
  if (frameCount % bloomInterval === 0 && !droughtActive) {
    bloomActive = true;
    bloomTimer = bloomDuration;
    for (let plant of plants) {
      if (plant.isAlive) {
        plant.flowering = true;
        plant.flowerTimer = bloomDuration;
      }
    }
  }
  if (bloomActive) {
    bloomTimer--;
    if (bloomTimer <= 0) bloomActive = false;
  }
  // === Update Nutrients ===
  noiseOffset += noiseDriftRate;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let noiseValue = noise(j * noiseScale, i * noiseScale + noiseOffset);
      soilGrid[i][j] += map(noiseValue, 0, 1, -noiseNutrientInfluence, noiseNutrientInfluence);
      soilGrid[i][j] = constrain(soilGrid[i][j], 0, 100);
    }
  }
  if (!droughtActive) {
    for (let well of nutrientWells) {
      for (let i = 0; i < rows; i++) {
        for (let j = 0; j < cols; j++) {
          let cellX = j * cellSize + cellSize / 2;
          let cellY = i * cellSize + cellSize / 2 + soilStartY;
          let d = dist(well.x, soilStartY + soilHeight / 2, cellX, cellY);
          if (d < well.radius) {
            let regenAmount = wellRegenRate * (1 - d / well.radius);
            soilGrid[i][j] = constrain(soilGrid[i][j] + regenAmount, 0, 100);
          }
        }
      }
    }
  }
  let nextSoilGrid = Array(rows).fill(0).map(() => Array(cols).fill(0));
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let currentNutrient = soilGrid[i][j];
      let neighborsNutrientSum = 0;
      let numNeighbors = 0;
      if (j > 0) { neighborsNutrientSum += soilGrid[i][j - 1]; numNeighbors++; }
      if (j < cols - 1) { neighborsNutrientSum += soilGrid[i][j + 1]; numNeighbors++; }
      if (i > 0) { neighborsNutrientSum += soilGrid[i - 1][j]; numNeighbors++; }
      if (i < rows - 1) { neighborsNutrientSum += soilGrid[i + 1][j]; numNeighbors++; }
      let avgNeighborNutrients = numNeighbors > 0 ? neighborsNutrientSum / numNeighbors : currentNutrient;
      nextSoilGrid[i][j] = constrain(currentNutrient * (1 - diffusionRate) + avgNeighborNutrients * diffusionRate, 0, 100);
    }
  }
  soilGrid = nextSoilGrid;
  lightNoiseOffset += lightDriftRate;
  let shadowMap = Array(cols).fill(0);
  let sortedPlants = [...plants].sort((a, b) => {
    if (!a.isAlive && !b.isAlive) return 0;
    if (!a.isAlive) return 1;
    if (!b.isAlive) return -1;
    let aHighestY = height;
    if (a.branches.length > 0) aHighestY = min(aHighestY, min(a.branches.map(b => min(b.startY, b.endY))));
    let bHighestY = height;
    if (b.branches.length > 0) bHighestY = min(bHighestY, min(b.branches.map(b => min(b.startY, b.endY))));
    return aHighestY - bHighestY;
  });
  for (let plant of sortedPlants) {
    if (!plant.isAlive) continue;
    let plantTotalLightExposure = 0;
    let plantBranchCount = 0;
    for (let branch of plant.branches) {
      let branchX1 = floor(min(branch.startX, branch.endX) / cellSize);
      let branchX2 = floor(max(branch.startX, branch.endX) / cellSize);
      branchX1 = constrain(branchX1, 0, cols - 1);
      branchX2 = constrain(branchX2, 0, cols - 1);
      let branchYBlock = floor(min(branch.startY, branch.endY));
      let unshadowedWidth = 0;
      let totalWidth = branchX2 - branchX1 + 1;
      for (let x = branchX1; x <= branchX2; x++) {
        if (branchYBlock < shadowMap[x]) {
          unshadowedWidth++;
        }
      }
      branch.lightExposure = unshadowedWidth / totalWidth;
      plantTotalLightExposure += branch.lightExposure;
      plantBranchCount++;
      for (let x = branchX1; x <= branchX2; x++) {
        shadowMap[x] = max(shadowMap[x], branchYBlock);
      }
    }
    plant.lightExposure = plantBranchCount > 0 ? plantTotalLightExposure / plantBranchCount : 1.0;
  }
  if (!seedRainActive) {
    seedSpawnTimer--;
    if (seedSpawnTimer <= 0) {
      seeds.push(new Seed());
      seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
    }
  }
  for (let i = seeds.length - 1; i >= 0; i--) {
    seeds[i].update();
    if (seeds[i].state === 'germinating' || seeds[i].y > height + seedSize) {
      seeds.splice(i, 1);
    }
  }
  let livingPlantsCount = 0;
  for (let i = plants.length - 1; i >= 0; i--) {
    plants[i].update();
    if (plants[i].isAlive) {
      livingPlantsCount++;
    } else if (plants[i].isDead()) {
      plantIDMap.delete(plants[i].id);
      plants.splice(i, 1);
    }
  }
  totalPlantsAlive = livingPlantsCount;
  for (let i = nutrientParticles.length - 1; i >= 0; i--) {
    nutrientParticles[i].update();
    if (nutrientParticles[i].isDissolved()) {
      nutrientParticles.splice(i, 1);
    }
  }
  aliveHistory.push(totalPlantsAlive);
  deadHistory.push(totalPlantsDead);
  let currentFertilitySum = 0;
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      currentFertilitySum += soilGrid[i][j];
    }
  }
  fertilityHistory.push(currentFertilitySum / (rows * cols));
  if (aliveHistory.length > historyLength) aliveHistory.shift();
  if (deadHistory.length > historyLength) deadHistory.shift();
  if (fertilityHistory.length > historyLength) fertilityHistory.shift();
}

๐Ÿ”ง Subcomponents:

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

Triggers a seed rain event at regular intervals where many seeds spawn at once

conditional Drought Event if (frameCount % (seedRainInterval + 500) === 0 && !droughtActive) { droughtActive = true; ... }

Triggers a drought that stops nutrient well regeneration and seed rain

conditional Bloom Event if (frameCount % bloomInterval === 0 && !droughtActive) { bloomActive = true; ... }

Triggers a bloom event where all living plants flower

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

Updates nutrient values with Perlin noise to create a breathing effect

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

Regenerates nutrients around each well based on distance, creating fertility zones

for-loop Nutrient Diffusion for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { ... } }

Spreads nutrients between neighboring soil cells to simulate diffusion

for-loop Shadow Casting and Light Exposure for (let plant of sortedPlants) { ... for (let branch of plant.branches) { ... } }

Calculates which plants are in shadow based on plants above them, affecting growth

conditional Normal Seed Spawning if (!seedRainActive) { seedSpawnTimer--; if (seedSpawnTimer <= 0) { ... } }

Spawns individual seeds at regular intervals when not in seed rain event

for-loop Timeline History Update aliveHistory.push(totalPlantsAlive); deadHistory.push(totalPlantsDead); ...

Records historical data for the timeline visualization at the bottom

Line by Line:

const soilStartY = height - soilHeight
Calculates the y-coordinate where the soil area begins (soil is at the bottom of the canvas)
if (frameCount % seedRainInterval === 0 && !droughtActive)
Checks if enough frames have passed to trigger a seed rain event and we're not in a drought
seedRainActive = true; seedRainTimer = seedRainCount
Activates seed rain mode and sets a timer for how many seeds to spawn
if (seedRainActive && seedRainTimer > 0) { seeds.push(new Seed()); seedRainTimer--; }
During seed rain, spawns one seed per frame and decrements the timer until all seeds are spawned
noiseOffset += noiseDriftRate
Slowly increments the Perlin noise offset to create a drifting, breathing effect
let noiseValue = noise(j * noiseScale, i * noiseScale + noiseOffset)
Gets a Perlin noise value for this cell using its position and the current noise offset
soilGrid[i][j] += map(noiseValue, 0, 1, -noiseNutrientInfluence, noiseNutrientInfluence)
Adds a small random perturbation to the nutrient value based on the noise value, making the soil appear to breathe
if (!droughtActive) { for (let well of nutrientWells) { ... } }
Only regenerates nutrients from wells when not in a drought, creating a realistic environmental effect
let d = dist(well.x, soilStartY + soilHeight / 2, cellX, cellY)
Calculates the distance from the well center to the current cell
let regenAmount = wellRegenRate * (1 - d / well.radius)
Regeneration decreases with distance from the well center, creating a gradient effect
let nextSoilGrid = Array(rows).fill(0).map(() => Array(cols).fill(0))
Creates a new grid to store the next state of nutrients before replacing the old grid
let avgNeighborNutrients = numNeighbors > 0 ? neighborsNutrientSum / numNeighbors : currentNutrient
Calculates the average nutrient value of neighboring cells
nextSoilGrid[i][j] = constrain(currentNutrient * (1 - diffusionRate) + avgNeighborNutrients * diffusionRate, 0, 100)
Blends the current cell's nutrients with its neighbors' nutrients to simulate diffusion spreading nutrients across the soil
let sortedPlants = [...plants].sort((a, b) => { ... return aHighestY - bHighestY; })
Sorts plants from top to bottom so that plants higher up can cast shadows on plants below them
if (branchYBlock < shadowMap[x]) { unshadowedWidth++; }
Checks if this branch is above the highest shadow cast in this column, meaning it gets light
branch.lightExposure = unshadowedWidth / totalWidth
Calculates what percentage of this branch is illuminated (not in shadow)
plant.lightExposure = plantBranchCount > 0 ? plantTotalLightExposure / plantBranchCount : 1.0
Averages the light exposure across all branches to get the plant's overall light exposure
plants[i].update(); if (plants[i].isAlive) { livingPlantsCount++; }
Updates each plant and counts how many are still alive
fertilityHistory.push(currentFertilitySum / (rows * cols))
Records the average soil fertility for the timeline visualization

drawVisuals()

This function handles all visual rendering. It draws the background, light layer, soil cells with nutrient-based colors, particles, plants, seeds, genealogy web, hover tooltips, and the timeline bar. The timeline at the bottom shows three metrics: green line for living plants, gray line for dead plants, and amber line for average soil fertility. The light layer creates a subtle atmospheric effect that drifts over time.

function drawVisuals() {
  background(0, 0, 5);
  const soilStartY = height - soilHeight;
  push();
  noStroke();
  for (let x = 0; x < width; x += cellSize) {
    let brightness = map(noise(x * lightNoiseScale + lightNoiseOffset), 0, 1, lightBrightnessMin, lightBrightnessMax);
    fill(0, 0, 100, brightness);
    rect(x, 0, cellSize, height);
  }
  pop();
  for (let i = 0; i < rows; i++) {
    for (let j = 0; j < cols; j++) {
      let nutrientValue = soilGrid[i][j];
      let currentHue = map(nutrientValue, 0, 100, depletedHue, richHue);
      let currentSat = map(nutrientValue, 0, 100, depletedSat, richSat);
      let currentBright = map(nutrientValue, 0, 100, depletedBright, richBright);
      fill(currentHue, currentSat, currentBright);
      rect(j * cellSize, i * cellSize + soilStartY, cellSize, cellSize);
    }
  }
  noStroke();
  for (let particle of nutrientParticles) {
    particle.draw();
  }
  if (showGenealogyWeb) {
    push();
    stroke(0, 0, 50, 15);
    strokeWeight(0.5);
    for (let plant of plants) {
      if (plant.offspringPlantIDs.length > 0) {
        for (let offspringID of plant.offspringPlantIDs) {
          let offspring = plantIDMap.get(offspringID);
          if (offspring) {
            line(plant.x, plant.y, offspring.x, offspring.y);
          }
        }
      }
    }
    pop();
  }
  for (let plant of plants) {
    plant.draw();
  }
  noStroke();
  for (let seed of seeds) {
    seed.draw();
  }
  let hoveredPlant = null;
  for (let i = plants.length - 1; i >= 0; i--) {
    let plant = plants[i];
    if (plant.isAlive || plant.decayTimer > 0) {
      if (dist(mouseX, mouseY, plant.x, plant.y) < plantBaseLength * 2) {
        hoveredPlant = plant;
        break;
      }
    }
  }
  if (hoveredPlant) {
    push();
    translate(mouseX + 10, mouseY + 10);
    fill(0, 0, 100, 80);
    noStroke();
    rect(0, 0, 150, 80);
    fill(0);
    textSize(10);
    textFont('Arial');
    text(`Age: ${frameCount - hoveredPlant.birthFrame} frames`, 5, 15);
    text(`Seed Type: ${hoveredPlant.seedShape}`, 5, 30);
    text(`Light: ${nf(hoveredPlant.lightExposure, 1, 2)}`, 5, 45);
    text(`Alive: ${hoveredPlant.isAlive}`, 5, 60);
    if (hoveredPlant.offspringPlantIDs.length > 0) {
      push();
      stroke(0, 0, 50, 50);
      strokeWeight(1);
      for (let offspringID of hoveredPlant.offspringPlantIDs) {
        let offspring = plantIDMap.get(offspringID);
        if (offspring) {
          line(hoveredPlant.x, hoveredPlant.y, offspring.x, offspring.y);
        }
      }
      pop();
    }
    pop();
  }
  push();
  translate(0, height - timelineHeight);
  fill(0, 0, 0, 50);
  noStroke();
  rect(0, 0, width, timelineHeight);
  const maxAlive = max(aliveHistory);
  const maxDead = max(deadHistory);
  const maxFertility = max(fertilityHistory);
  const maxCombined = max(maxAlive, maxDead, maxFertility);
  noFill();
  stroke(120, 80, 70);
  strokeWeight(2);
  beginShape();
  vertex(0, timelineHeight);
  for (let i = 0; i < aliveHistory.length; i++) {
    let x = map(i, 0, historyLength - 1, 0, width);
    let y = map(aliveHistory[i], 0, maxCombined, timelineHeight, 0);
    vertex(x, y);
  }
  vertex(width, timelineHeight);
  endShape();
  stroke(0, 0, 50);
  beginShape();
  vertex(0, timelineHeight);
  for (let i = 0; i < deadHistory.length; i++) {
    let x = map(i, 0, historyLength - 1, 0, width);
    let y = map(deadHistory[i], 0, maxCombined, timelineHeight, 0);
    vertex(x, y);
  }
  vertex(width, timelineHeight);
  endShape();
  stroke(richHue, richSat, richBright);
  beginShape();
  vertex(0, timelineHeight);
  for (let i = 0; i < fertilityHistory.length; i++) {
    let x = map(i, 0, historyLength - 1, 0, width);
    let y = map(fertilityHistory[i], 0, 100, timelineHeight, 0);
    vertex(x, y);
  }
  vertex(width, timelineHeight);
  endShape();
  pop();
}

๐Ÿ”ง Subcomponents:

calculation Clear Canvas background(0, 0, 5)

Clears the canvas each frame with a very dark gray background

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

Draws semi-transparent light bands that drift across the canvas using Perlin noise

for-loop Draw Soil Cells for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { ... } }

Draws each soil cell with a color based on its nutrient value

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

Detects which plant the mouse is hovering over and displays information

for-loop Timeline Visualization for (let i = 0; i < aliveHistory.length; i++) { ... }

Draws three line graphs showing alive plants, dead plants, and soil fertility over time

Line by Line:

background(0, 0, 5)
Clears the entire canvas with a very dark gray color (HSB: hue=0, sat=0, brightness=5)
let brightness = map(noise(x * lightNoiseScale + lightNoiseOffset), 0, 1, lightBrightnessMin, lightBrightnessMax)
Creates a brightness value for the light band using Perlin noise, making the light pattern drift smoothly
fill(0, 0, 100, brightness)
Sets the fill color to white with varying transparency based on the noise brightness
let currentHue = map(nutrientValue, 0, 100, depletedHue, richHue)
Maps nutrient values (0-100) to hue values, creating a color gradient from brown (depleted) to amber (rich)
let currentSat = map(nutrientValue, 0, 100, depletedSat, richSat)
Maps nutrient values to saturation, making depleted soil grayish and rich soil vivid
let currentBright = map(nutrientValue, 0, 100, depletedBright, richBright)
Maps nutrient values to brightness, making depleted soil dark and rich soil bright
rect(j * cellSize, i * cellSize + soilStartY, cellSize, cellSize)
Draws a rectangle for each soil cell at the correct position, with soilStartY offsetting it to the bottom of the canvas
if (dist(mouseX, mouseY, plant.x, plant.y) < plantBaseLength * 2)
Checks if the mouse is within a certain distance of the plant's root position
text(`Age: ${frameCount - hoveredPlant.birthFrame} frames`, 5, 15)
Displays the plant's age by calculating the difference between current frame and birth frame
const maxCombined = max(maxAlive, maxDead, maxFertility)
Uses a common maximum value for all three timeline graphs so they scale proportionally
let y = map(aliveHistory[i], 0, maxCombined, timelineHeight, 0)
Maps the alive count to a y-position on the timeline, with 0 at the bottom and maxCombined at the top

draw()

The draw() function is the main loop that runs ~60 times per second. It separates simulation logic from rendering: the simulation can run multiple times per frame (for fast-forward), but visuals are always drawn once. This allows the user to speed up the simulation without making the animation choppy.

function draw() {
  let simulationSteps = fastForward ? 5 : 1;
  for (let i = 0; i < simulationSteps; i++) {
    updateSimulationLogic();
  }
  drawVisuals();
}

๐Ÿ”ง Subcomponents:

conditional Fast-Forward Mode Check let simulationSteps = fastForward ? 5 : 1

Determines whether to run the simulation 1 time (normal) or 5 times (fast-forward) per frame

for-loop Simulation Steps Loop for (let i = 0; i < simulationSteps; i++) { updateSimulationLogic(); }

Runs the simulation logic multiple times per frame when fast-forward is enabled

Line by Line:

let simulationSteps = fastForward ? 5 : 1
If fastForward is true, run 5 simulation steps; otherwise run 1 step per frame
for (let i = 0; i < simulationSteps; i++) { updateSimulationLogic(); }
Runs the simulation logic (events, nutrients, plants, etc.) the determined number of times
drawVisuals()
Draws everything to the canvas once per frame, regardless of how many simulation steps ran

windowResized()

This function is automatically called by p5.js whenever the browser window is resized. It recalculates the soil grid dimensions and clears all organisms to prevent positioning errors. The simulation restarts with a fresh ecosystem.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  initializeSoil();
  seeds = [];
  plants = [];
  plantIDMap.clear();
  totalPlantsAlive = 0;
  totalPlantsDead = 0;
  aliveHistory = [];
  deadHistory = [];
  fertilityHistory = [];
  seedSpawnTimer = floor(random(seedSpawnIntervalMin, seedSpawnIntervalMax + 1));
}

Line by Line:

resizeCanvas(windowWidth, windowHeight)
Resizes the p5.js canvas to match the new browser window dimensions
initializeSoil()
Reinitializes the soil grid and nutrient wells for the new canvas size
seeds = []; plants = []; plantIDMap.clear()
Clears all existing seeds and plants since they were positioned for the old canvas size
aliveHistory = []; deadHistory = []; fertilityHistory = []
Clears the timeline history to start fresh with the new canvas

keyTyped()

This function responds to keyboard input. Press 'F' to toggle fast-forward mode (runs simulation 5x faster), and 'G' to toggle the genealogy web (shows family relationships between plants).

function keyTyped() {
  if (key === 'f' || key === 'F') {
    fastForward = !fastForward;
  }
  if (key === 'g' || key === 'G') {
    showGenealogyWeb = !showGenealogyWeb;
  }
}

Line by Line:

if (key === 'f' || key === 'F') { fastForward = !fastForward; }
Toggles fast-forward mode on/off when the user presses 'f' or 'F'
if (key === 'g' || key === 'G') { showGenealogyWeb = !showGenealogyWeb; }
Toggles the genealogy web visualization on/off when the user presses 'g' or 'G'

polygon(x, y, radius, npoints)

This helper function draws regular polygons (pentagons, hexagons, etc.) by placing vertices evenly around a circle. It's used to draw pentagon and hexagon seeds. The npoints parameter determines how many sides the polygon has.

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);
}

๐Ÿ”ง Subcomponents:

calculation Calculate Angle Between Points let angle = TWO_PI / npoints

Divides the full circle into equal angles for each vertex

for-loop Vertex Generation Loop for (let a = 0; a < TWO_PI; a += angle) { ... }

Calculates and creates vertices around a circle to form a regular polygon

Line by Line:

let angle = TWO_PI / npoints
Calculates the angle between each vertex. TWO_PI is 360 degrees, divided by the number of points
for (let a = 0; a < TWO_PI; a += angle)
Loops around the full circle (0 to 2ฯ€), incrementing by the angle between vertices
let sx = x + cos(a) * radius; let sy = y + sin(a) * radius
Uses trigonometry to calculate the x,y position of each vertex on a circle
vertex(sx, sy)
Adds this point as a vertex to the shape being drawn
endShape(CLOSE)
Closes the shape by connecting the last vertex back to the first

๐Ÿ“ฆ Key Variables

cellSize number

Size of each soil cell in pixels (10x10). Determines the resolution of the soil grid

const cellSize = 10;
soilHeight number

Height of the soil area from the bottom of the canvas in pixels

const soilHeight = 150;
rows, cols number

Number of rows and columns in the soil grid, calculated based on canvas size and cellSize

let rows, cols;
soilGrid array

2D array storing nutrient values (0-100) for each soil cell

let soilGrid;
noiseOffset number

Offset for Perlin noise that drifts each frame to create a breathing effect

let noiseOffset = 0;
nutrientWells array

Array of well objects that regenerate nutrients in their radius of influence

let nutrientWells = [];
seeds array

Array of Seed objects currently falling or dormant in the soil

let seeds = [];
seedSpawnTimer number

Countdown timer for spawning the next individual seed

let seedSpawnTimer = 0;
nutrientParticles array

Array of NutrientParticle objects that fall and dissolve into the soil when plants decay

let nutrientParticles = [];
seedRainActive boolean

Flag indicating whether a seed rain event is currently happening

let seedRainActive = false;
seedRainTimer number

Countdown for how many more seeds to spawn during a seed rain event

let seedRainTimer = 0;
droughtActive boolean

Flag indicating whether a drought event is currently happening

let droughtActive = false;
droughtTimer number

Countdown for how long the drought lasts

let droughtTimer = 0;
bloomActive boolean

Flag indicating whether a bloom event is currently happening

let bloomActive = false;
bloomTimer number

Countdown for how long the bloom event lasts

let bloomTimer = 0;
plants array

Array of Plant objects that are growing in the soil

let plants = [];
plantIDMap object

Map storing plants by their unique ID for quick lookup and genealogy tracking

let plantIDMap = new Map();
totalPlantsAlive number

Current count of living plants, updated each frame

let totalPlantsAlive = 0;
totalPlantsDead number

Cumulative count of plants that have died, never decreases

let totalPlantsDead = 0;
lightNoiseOffset number

Offset for Perlin noise that controls the drifting light bands

let lightNoiseOffset = 0;
aliveHistory array

Array storing the count of living plants for each frame, used for timeline visualization

let aliveHistory = [];
deadHistory array

Array storing cumulative dead plant counts for each frame, used for timeline visualization

let deadHistory = [];
fertilityHistory array

Array storing average soil fertility for each frame, used for timeline visualization

let fertilityHistory = [];
showGenealogyWeb boolean

Flag to toggle the display of genealogy lines connecting parent and offspring plants

let showGenealogyWeb = false;
fastForward boolean

Flag to toggle fast-forward mode, which runs simulation 5x faster

let fastForward = false;

๐Ÿงช Try This!

Experiment with the code by making these changes:

  1. Press 'F' to toggle fast-forward mode and watch the ecosystem evolve 5x faster. Notice how plants grow, compete for light, and die more quickly.
  2. Press 'G' to show the genealogy web. Hover over plants to see their family relationships and observe how plant lineages branch and spread across the soil.
  3. Change the `wellRegenRate` constant from 0.15 to 0.5 to make nutrient wells much more powerful. Watch how plants cluster around the wells and the entire ecosystem becomes more fertile.
  4. Modify `plantMaxIterations` from 4 to 6 to allow plants to grow more complex branching structures. Notice how they consume more nutrients and cast deeper shadows.
  5. Change `shadowDeathThreshold` from 0.3 to 0.1 to make plants more tolerant of shadow. Watch how plants can now survive in darker areas and create a denser forest.
  6. Increase `seedRainCount` from 20 to 100 to create massive seed rain events. Observe how the population explodes and then crashes as resources become scarce.
  7. Modify the `plantColorMap` object to change seed colors. For example, change triangle seeds from `{hue: 20, sat: 80, bright: 60}` to `{hue: 120, sat: 80, bright: 60}` to make them green instead of brown.
  8. Change `diffusionRate` from 0.03 to 0.15 to make nutrients spread much faster through the soil. Notice how the nutrient patterns become more uniform and less patchy.
  9. Reduce `droughtDuration` from 300 to 100 frames to make droughts shorter and less devastating. Watch how the ecosystem recovers faster between droughts.
  10. Modify the Seed class constructor to always spawn seeds at `this.shape = 'triangle'` instead of random. Observe how a monoculture of one plant type behaves differently than a diverse ecosystem.
Open in Editor & Experiment โ†’

๐Ÿ”ง Potential Improvements

Here are some ways this code could be enhanced:

PERFORMANCE updateSimulationLogic() - shadow calculation

The shadow calculation loops through all plants and all their branches, then loops through all columns for each branch. With many plants and branches, this becomes O(nยฒ) or worse, causing slowdown with large populations.

๐Ÿ’ก Cache shadow calculations or use a more efficient spatial partitioning system. Consider only recalculating shadows for plants that have changed position or structure.

BUG Seed.update() - soil collision detection

Seeds can get stuck at the soil surface if they land exactly on the boundary. The collision detection snaps them to the cell center, but doesn't account for seeds that land between cells.

๐Ÿ’ก Use a more robust collision detection that checks if the seed has crossed the soil boundary rather than checking an exact position. Consider: `if (this.y + this.vy >= soilStartY && this.y < soilStartY)`

BUG Plant.draw() - branch drawing logic

The plant drawing code recalculates all branches from the L-system axiom every frame, but the branches array already contains the correct positions. This is redundant and could cause visual inconsistencies if the axiom changes during drawing.

๐Ÿ’ก Use the stored branches array directly instead of re-interpreting the axiom. The branches array already has all the correct positions and angles.

FEATURE Plant class

Plants don't have a maximum lifespan. They can theoretically live forever if they have enough light and nutrients, making the ecosystem less dynamic.

๐Ÿ’ก Add an age-based death mechanic. For example: `if (frameCount - this.birthFrame > 5000) { this.isAlive = false; }` to make plants die after ~83 seconds of life.

STYLE updateSimulationLogic() - event management

Event management code (seed rain, drought, bloom) uses hardcoded frame intervals that are tightly coupled. If you want to adjust event timing, you have to edit multiple places.

๐Ÿ’ก Create an event manager object or class that handles all event scheduling in one place, making it easier to adjust timing and add new events.

BUG NutrientParticle.dissolve()

When a nutrient particle dissolves, it adds nutrients to a soil cell, but there's no check to ensure the particle actually landed in the soil. If a particle somehow ends up above the soil line, it will try to access an invalid grid position.

๐Ÿ’ก Add boundary checking: `if (row >= 0 && row < rows && col >= 0 && col < cols) { soilGrid[row][col] = ... }`

PERFORMANCE drawVisuals() - light layer

The light layer is drawn every frame by looping through every x position and calling noise(). With a large canvas, this can be expensive.

๐Ÿ’ก Cache the light layer in a graphics buffer and only update it when lightNoiseOffset changes significantly, or reduce the resolution of the light bands.

BUG Plant.generateLSystem()

When a plant reaches maxIterations, it stops growing. However, the growthTimer continues to decrement, and the plant will keep checking if it should grow, wasting CPU cycles.

๐Ÿ’ก Add an early return or flag to skip growth updates entirely once maxIterations is reached: `if (this.growthIterations >= this.maxIterations) { return; }`

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
Code Flow Diagram