FractalGarden

This sketch grows a living miniature ecosystem: a shifting band of soil rendered from a nutrient grid that diffuses and regenerates around hidden wells, while seeds drift down through gravity and wind, land in the dirt, and sprout into branching, shape-specific plants that compete for nutrients and space.

🧪 Try This!

Experiment with the code by making these changes:

  1. Supercharge the nutrient wells — Increasing regenerationRate makes soil near wells glow brighter and richer much faster, speeding up germination everywhere.
  2. Make seeds fall faster — Raising gravity makes falling seeds accelerate downward much more quickly, so they hit the soil sooner and rotate faster while falling.
  3. Rainbow triangle plants — Widening the hue range for triangle-seeded plants turns their normally warm orange color into a full rainbow of possible hues.
Prefer the full editor? Open it there →

📖 About This Sketch

FractalGarden simulates a strip of living soil as a grid of cells whose nutrient values rise near hidden 'wells', spread to their neighbors, and ripple with Perlin noise, all rendered as a glowing HSB gradient. Falling seeds respond to gravity and a tiny random wind force, sink into the dirt, and either sprout into a Plant or lie dormant if the soil beneath them is too poor. Each Plant grows procedurally, branch by branch, using shape-specific rules - angular forking for triangles, blocky right-angle turns for squares, and spiraling curves for pentagons/hexagons - so every plant looks different depending on which seed made it. Under the hood this is all powered by 2D arrays, noise(), HSB colorMode, p5.Vector math, and small ES6 classes.

The code is organized around a single evolving soilGrid array that setup() builds and draw() updates every frame through regeneration, noise-drift, and diffusion passes before it's painted as colored rectangles. Seeds and Plants are separate classes stored in arrays and are updated/drawn/removed each frame using backwards for-loops (safe for splicing). Studying this sketch teaches you how to simulate diffusion with a 'double buffer' grid, how to drive procedural/fractal growth with recursion-like branching and p5.Vector rotation, and how classes can encapsulate independent, interacting behaviors.

⚙️ How It Works

  1. When the sketch loads, setup() creates a full-window canvas, switches to HSB color, sizes the soil grid from the canvas dimensions and cellSize, and calls initSoilGridAndWells() to fill the grid with noisy starting nutrient values and scatter 5-8 nutrient wells.
  2. Every frame, draw() first lets each well regenerate nutrients in the cells around it, then computes a fresh temporary grid combining Perlin-noise drift with diffusion (each cell nudged toward the average of its neighbors), and swaps that in as the new soilGrid.
  3. The nutrient grid is rendered as small rectangles whose hue, saturation and brightness are mapped from each cell's nutrient level, producing the soil's warm glowing gradient.
  4. A timer periodically spawns a Seed above the canvas; gravity and a small random wind push it downward until it hits the soil surface, where it either goes dormant (if nutrients are below 40) or waits to germinate.
  5. Once the soil beneath a landed seed exceeds 40 nutrients, draw() creates a Plant at that spot, consumes 30 nutrients from the cell, and removes the seed; the Plant then grows new branches every frame using rules tied to its original seed shape, consuming nutrients as it goes.
  6. Resizing the browser window fires windowResized(), which rebuilds the canvas and grid dimensions and clears wells, seeds, and plants so the whole ecosystem re-forms to fit the new landscape.

🎓 Concepts You'll Learn

2D array gridsPerlin noiseHSB color modeObject-oriented classes (Seed, Branch, Plant)p5.Vector math and rotationDiffusion/cellular simulationFractal/recursive branchingSafe array removal via backward loops

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It's the right place to configure the canvas, color mode, and any grids or arrays that depend on the canvas size.

function setup() {
  createCanvas(windowWidth, windowHeight); // Create a canvas that fills the entire browser window.
  noStroke();                              // Disable drawing outlines around shapes for a smoother look.
  colorMode(HSB, 360, 100, 100);           // Set color mode to HSB (Hue, Saturation, Brightness)
                                           // with ranges 0-360 for Hue and 0-100 for Saturation/Brightness.

  // Calculate the number of columns and rows for the soil grid based on canvas size and cell size.
  cols = floor(width / cellSize);
  rows = floor(soilHeight / cellSize);

  // Initialize the soil grid with starting nutrient values and create the nutrient wells.
  initSoilGridAndWells();

  // Initialize the first seed spawn time.
  nextSeedSpawnTime = floor(random(60, 120));
}
Line-by-line explanation (6 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas exactly fill the browser window, so the sketch always uses the full available space.
colorMode(HSB, 360, 100, 100);
Switches from the default RGB color system to Hue/Saturation/Brightness, which makes it easy to shift colors smoothly by just changing one number (hue).
cols = floor(width / cellSize);
Figures out how many grid columns fit across the canvas width given each cell's pixel size.
rows = floor(soilHeight / cellSize);
Figures out how many grid rows fit inside the soil band's height.
initSoilGridAndWells();
Calls the helper that fills soilGrid with starting nutrient values and randomly places the nutrient wells.
nextSeedSpawnTime = floor(random(60, 120));
Picks a random number of frames (1-2 seconds at 60fps) to wait before the very first seed drops.

draw()

draw() is the animation engine of the sketch. Each of its four phases - well regeneration, noise/diffusion, visualization, and seed/plant management - runs every frame at 60fps, and the order matters: soilGrid must be fully updated before it's drawn and before seeds check it for germination.

🔬 This is what makes soil glow brighter near each well. What happens if you double regenerationRate here, or shrink wellRadiusCells so wells only affect their immediate cell?

        if (distance <= wellRadiusCells) {
          // Regenerate nutrients, with a stronger effect closer to the well.
          let regenerationAmount = map(distance, 0, wellRadiusCells, regenerationRate, 0);
          soilGrid[r][c] = constrain(soilGrid[r][c] + regenerationAmount, 0, 100);
        }
function draw() {
  background(0, 0, 5); // Draw a very dark gray background, clearing the canvas each frame.

  // --- Nutrient Well Regeneration ---
  // Wells slowly regenerate nutrients within their radius.
  for (let well of wells) {
    let wellCol = floor(well.x / cellSize); // Convert well's pixel x-position to grid column index.
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        // Calculate the distance between the current cell and the well's center in cell units.
        let distance = dist(c, r, wellCol, well.y);
        if (distance <= wellRadiusCells) {
          // Regenerate nutrients, with a stronger effect closer to the well.
          let regenerationAmount = map(distance, 0, wellRadiusCells, regenerationRate, 0);
          soilGrid[r][c] = constrain(soilGrid[r][c] + regenerationAmount, 0, 100);
        }
      }
    }
  }

  // --- Perlin Noise Drift and Nutrient Diffusion ---
  let newSoilGrid = [];
  for (let r = 0; r < rows; r++) {
    newSoilGrid[r] = [];
    for (let c = 0; c < cols; c++) {

      let noiseValue = noise(c * noiseScale, r * noiseScale, perlinNoiseOffset);
      let noiseEffect = (noiseValue - 0.5) * noiseAmplitude;

      let currentNutrient = soilGrid[r][c];
      let nutrientAfterNoise = constrain(currentNutrient + noiseEffect, 0, 100);

      let sumNeighbors = 0;
      let numNeighbors = 0;

      let neighbors = [[-1, 0], [1, 0], [0, -1], [0, 1]];

      for (let neighbor of neighbors) {
        let nr = r + neighbor[0];
        let nc = c + neighbor[1];

        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
          sumNeighbors += soilGrid[nr][nc];
          numNeighbors++;
        }
      }

      let neighborAverage = sumNeighbors / numNeighbors;
      let nutrientAfterDiffusion = nutrientAfterNoise + diffusionRate * (neighborAverage - nutrientAfterNoise);

      newSoilGrid[r][c] = constrain(nutrientAfterDiffusion, 0, 100);
    }
  }

  soilGrid = newSoilGrid;

  perlinNoiseOffset += 0.005;

  // --- Visualize Soil ---
  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      let nutrient = soilGrid[r][c];
      let x = c * cellSize;
      let y = height - soilHeight + r * cellSize;

      let h = map(nutrient, 0, 100, 40, 15);
      let s = map(nutrient, 0, 100, 10, 90);
      let b = map(nutrient, 0, 100, 20, 90);

      fill(h, s, b);
      rect(x, y, cellSize, cellSize);
    }
  }

  // --- Seed Spawning ---
  seedSpawnTimer++;
  if (seedSpawnTimer >= nextSeedSpawnTime) {
    seeds.push(new Seed());
    seedSpawnTimer = 0;
    nextSeedSpawnTime = floor(random(60, 120));
  }

  // --- Update and Draw Seeds ---
  for (let i = seeds.length - 1; i >= 0; i--) {
    let seed = seeds[i];
    seed.update();
    seed.draw();

    if (seed.dormant) {
      if (seed.soilCellRow >= 0 && seed.soilCellRow < rows &&
          seed.soilCellCol >= 0 && seed.soilCellCol < cols) {
        let nutrientValue = soilGrid[seed.soilCellRow][seed.soilCellCol];
        if (nutrientValue > 40) {
          seed.dormant = false;
          plants.push(new Plant(seed.x, seed.y, seed.shapeType, frameCount));
          soilGrid[seed.soilCellRow][seed.soilCellCol] = constrain(nutrientValue - 30, 0, 100);
          seeds.splice(i, 1);
        }
      } else {
        seeds.splice(i, 1);
      }
    }
    else if (seed.y > height - soilHeight + rows * cellSize && !seed.dormant) {
      seeds.splice(i, 1);
    }
  }

  // --- Update and Draw Plants ---
  for (let i = plants.length - 1; i >= 0; i--) {
    let plant = plants[i];
    plant.update();
    plant.draw();

    if (plant.branches.length === 0) {
      plants.splice(i, 1);
    }
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

for-loop Well Regeneration Loop for (let well of wells) {

Scans the whole grid for every well and boosts nutrients in cells within wellRadiusCells of it.

for-loop Noise + Diffusion Pass for (let r = 0; r < rows; r++) {

Builds a brand-new grid each frame combining Perlin noise drift with averaging toward neighboring cells.

conditional Seed Germination Check if (nutrientValue > 40) {

Decides whether a dormant seed has enough nutrients underneath it to sprout into a Plant.

conditional Plant Removal Check if (plant.branches.length === 0) {

Removes plants from the array once they have no branches left to draw or grow.

background(0, 0, 5);
Paints the whole canvas a near-black color every frame, erasing the previous frame so nothing smears.
let distance = dist(c, r, wellCol, well.y);
Measures how far the current cell is from a well's center, in grid-cell units rather than pixels.
let regenerationAmount = map(distance, 0, wellRadiusCells, regenerationRate, 0);
Cells right next to a well get the full regenerationRate boost, while cells at the edge of its radius get almost none.
let noiseEffect = (noiseValue - 0.5) * noiseAmplitude;
Converts Perlin noise's 0-1 output into a signed value centered on 0, so it can push nutrients up or down.
let neighborAverage = sumNeighbors / numNeighbors;
Averages the nutrient values of up to 4 neighboring cells (accounting for edges having fewer neighbors).
let nutrientAfterDiffusion = nutrientAfterNoise + diffusionRate * (neighborAverage - nutrientAfterNoise);
Nudges the cell's value a fraction of the way toward its neighbors' average, which is what makes nutrients spread out smoothly over time.
soilGrid = newSoilGrid;
Swaps in the freshly computed grid all at once, so every cell updates using consistent, simultaneous data rather than partially-updated values.
let h = map(nutrient, 0, 100, 40, 15);
Maps nutrient level to a hue between amber (40) and red (15), so richer soil looks different from poorer soil.
if (seedSpawnTimer >= nextSeedSpawnTime) {
Checks whether enough frames have passed to spawn a new falling seed.
if (nutrientValue > 40) {
Germination threshold - a dormant seed only sprouts once the soil beneath it has more than 40 nutrients.
plants.push(new Plant(seed.x, seed.y, seed.shapeType, frameCount));
Creates a brand-new Plant object at the seed's landing spot, inheriting its shape type which determines its growth style.

initSoilGridAndWells()

This function is the 'reset' routine for the soil simulation - it's called both at startup and whenever the window resizes, so the grid and wells always match the current canvas dimensions.

🔬 This loop decides how many wells exist and where. What happens if you change random(5, 9) to random(20, 30) for many small hotspots? What if you set y to 0 instead of rows/2 so wells sit at the very top of the soil?

  numWells = floor(random(5, 9)); // Randomly choose between 5 and 8 wells.
  for (let i = 0; i < numWells; i++) {
    let x = random(width); // Random x-position across the canvas width.
    let y = rows / 2;      // Wells are conceptually at the "surface" of the soil grid (middle row).
    wells.push({ x: x, y: y, radius: wellRadiusCells }); // Add well to the array.
  }
function initSoilGridAndWells() {
  soilGrid = []; // Clear the existing soil grid.
  wells = [];    // Clear the existing wells.

  // Initialize the soil grid with random initial nutrient values,
  // enhanced by Perlin noise for a more natural starting distribution.
  for (let r = 0; r < rows; r++) {
    soilGrid[r] = []; // Create a new row.
    for (let c = 0; c < cols; c++) {
      let initialNutrient = random(20, 80); // Start with values between 20 and 80.
      let noiseVal = noise(c * noiseScale, r * noiseScale, perlinNoiseOffset);
      // Add a scaled Perlin noise effect (more pronounced than the drift in draw()).
      initialNutrient += (noiseVal - 0.5) * noiseAmplitude * 2;
      soilGrid[r][c] = constrain(initialNutrient, 0, 100); // Clamp values to 0-100.
    }
  }

  // Initialize nutrient wells with random x-positions.
  numWells = floor(random(5, 9)); // Randomly choose between 5 and 8 wells.
  for (let i = 0; i < numWells; i++) {
    let x = random(width); // Random x-position across the canvas width.
    let y = rows / 2;      // Wells are conceptually at the "surface" of the soil grid (middle row).
    wells.push({ x: x, y: y, radius: wellRadiusCells }); // Add well to the array.
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Grid Initialization Loop for (let r = 0; r < rows; r++) {

Fills every cell of soilGrid with a random base nutrient value shaped by Perlin noise.

for-loop Wells Placement Loop for (let i = 0; i < numWells; i++) {

Scatters a random number of nutrient wells at random x-positions.

soilGrid = []; // Clear the existing soil grid.
Wipes out any previous grid so a brand-new one can be built (important when the canvas resizes).
let initialNutrient = random(20, 80); // Start with values between 20 and 80.
Gives every cell a random starting nutrient value in the middle range so the soil begins neither empty nor full.
initialNutrient += (noiseVal - 0.5) * noiseAmplitude * 2;
Layers Perlin noise on top of the random base so nearby cells vary smoothly instead of looking like pure static.
numWells = floor(random(5, 9)); // Randomly choose between 5 and 8 wells.
Picks how many nutrient wells will exist this run, adding variety between reloads/resizes.
let y = rows / 2; // Wells are conceptually at the "surface" of the soil grid (middle row).
Places every well at the same vertical row (the middle), so their influence radiates mainly left-right and up-down from that row.

windowResized()

windowResized() is a special p5.js function that p5 calls for you automatically - you never call it yourself. It's the standard place to redo any size-dependent setup, like grids, when the browser window changes.

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

  // Recalculate cols and rows for the soil area based on the new canvas size.
  cols = floor(width / cellSize);
  rows = floor(soilHeight / cellSize);

  // Reinitialize the soil grid and wells to adapt to the new canvas dimensions.
  initSoilGridAndWells();

  // Reset seed spawning timer
  seedSpawnTimer = 0;
  nextSeedSpawnTime = floor(random(60, 120));
  seeds = []; // Clear seeds on resize
  plants = []; // Clear plants on resize
}
Line-by-line explanation (5 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js callback that fires automatically whenever the browser window changes size; this line makes the canvas match the new dimensions.
cols = floor(width / cellSize);
Recomputes how many grid columns fit in the new, resized canvas width.
initSoilGridAndWells();
Rebuilds the whole soil grid and wells from scratch so the simulation fits the new size instead of leaving stale, mismatched data.
seeds = []; // Clear seeds on resize
Removes all currently falling seeds since their positions were calculated for the old canvas size.
plants = []; // Clear plants on resize
Removes all grown plants too, so the whole ecosystem restarts fresh on the newly-sized landscape.

drawPolygon()

This is a reusable geometry helper: rather than hard-coding separate drawing code for triangles, pentagons, and hexagons, it uses trigonometry (cos/sin) to generate any regular polygon from a single formula.

🔬 This is used to draw every seed's shape (triangle, square, pentagon, hexagon). What happens if you multiply radius by (1 + 0.3 * sin(a * 3)) inside sx and sy, turning straight polygon edges into a wavy flower shape?

  for (let a = 0; a < TWO_PI; a += angle) {
    let sx = x + cos(a) * radius;
    let sy = y + sin(a) * radius;
    vertex(sx, sy);
  }
function drawPolygon(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 (5 lines)

🔧 Subcomponents:

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

Walks around a full circle in equal angle steps, placing one vertex per step to form a regular polygon.

let angle = TWO_PI / npoints;
Divides a full circle (TWO_PI radians) into npoints equal slices - 3 slices makes a triangle, 6 makes a hexagon, etc.
beginShape();
Tells p5.js you're about to define a custom shape one vertex at a time.
let sx = x + cos(a) * radius;
Uses cosine to find the x-offset for a point at angle a on a circle of the given radius.
let sy = y + sin(a) * radius;
Uses sine to find the matching y-offset, together placing the point on the circle's edge.
endShape(CLOSE);
Finishes the shape and draws a line back to the first vertex, closing the polygon.

rotateVector()

Every branch direction in a Plant is computed by rotating its parent's direction vector by a random angle using this function - it's the core math trick that lets branches fork left and right instead of always growing perfectly straight.

function rotateVector(v, angle) {
  let x = v.x * cos(angle) - v.y * sin(angle);
  let y = v.x * sin(angle) + v.y * cos(angle);
  return createVector(x, y);
}
Line-by-line explanation (3 lines)
let x = v.x * cos(angle) - v.y * sin(angle);
This is the standard 2D rotation matrix formula for the new x-component - it mixes the old x and y using cosine and sine of the rotation angle.
let y = v.x * sin(angle) + v.y * cos(angle);
The matching formula for the new y-component, completing the rotation.
return createVector(x, y);
Packages the rotated coordinates into a fresh p5.Vector so the original vector isn't modified.

Seed (class)

The Seed class shows a common pattern: an object with its own update() (physics/logic) and draw() (rendering) methods, plus a state flag (dormant) that changes its behavior. It's the bridge between falling particles and the Plant class - each germinated seed hands off its shapeType to a new Plant.

class Seed {
  constructor() {
    this.x = random(width);
    this.y = -10; // Start above the canvas
    this.vx = random(-0.2, 0.2); // Initial horizontal velocity (tiny wind)
    this.vy = 0; // Vertical velocity (gravity will affect this)
    this.size = random(3, 7); // Small seed size
    this.shapeType = random(['triangle', 'square', 'pentagon', 'hexagon']); // Random shape
    this.dormant = false;
    this.soilCellCol = -1;
    this.soilCellRow = -1;
  }

  update() {
    if (this.dormant) {
      return;
    }

    this.vy += gravity;

    this.vx += random(-windMagnitude, windMagnitude);
    this.vx = constrain(this.vx, -1, 1);

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

    this.x = constrain(this.x, 0, width);

    let soilSurfaceY = height - soilHeight;
    if (this.y >= soilSurfaceY) {
      this.y = soilSurfaceY;
      this.vy = 0;
      this.vx = 0;

      this.soilCellCol = floor(this.x / cellSize);
      this.soilCellRow = floor((this.y - soilSurfaceY) / cellSize);

      this.soilCellCol = constrain(this.soilCellCol, 0, cols - 1);
      this.soilCellRow = constrain(this.soilCellRow, 0, rows - 1);

      let nutrientValue = soilGrid[this.soilCellRow][this.soilCellCol];

      if (nutrientValue > 40) {
        // Germinate! (Handled in draw() to create Plant and consume nutrients)
      } else {
        this.dormant = true;
      }
    }
  }

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

    if (this.dormant) {
      fill(0, 0, 40, 70);
    } else {
      fill(30, 70, 80);
    }

    switch (this.shapeType) {
      case 'triangle':
        drawPolygon(0, 0, this.size, 3);
        break;
      case 'square':
        rectMode(CENTER);
        rect(0, 0, this.size * 1.2, this.size * 1.2);
        break;
      case 'pentagon':
        drawPolygon(0, 0, this.size, 5);
        break;
      case 'hexagon':
        drawPolygon(0, 0, this.size, 6);
        break;
    }
    pop();
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Dormant Early Return if (this.dormant) {

Stops a dormant seed from moving or being re-checked once it has settled without germinating.

conditional Soil Collision Check if (this.y >= soilSurfaceY) {

Detects when the falling seed reaches the soil surface, snaps it in place, and figures out which grid cell it landed on.

switch-case Shape Drawing Switch switch (this.shapeType) {

Chooses which polygon-drawing call to make based on the seed's randomly assigned shape.

this.shapeType = random(['triangle', 'square', 'pentagon', 'hexagon']); // Random shape
Randomly assigns one of four shapes to this seed; this choice later determines both its drawn shape and its plant's growth style and color.
this.vy += gravity;
Accelerates the seed downward a little more each frame, simulating gravity.
this.vx += random(-windMagnitude, windMagnitude);
Adds a tiny random nudge left or right each frame, simulating gusts of wind.
let soilSurfaceY = height - soilHeight;
Calculates the pixel y-coordinate where the soil begins (the top edge of the soil band).
this.soilCellCol = floor(this.x / cellSize);
Converts the seed's pixel x-position into a soil grid column index so it can check nutrients there.
if (nutrientValue > 40) {
Checks if there's enough nutrient to germinate - but notice the actual germination happens in draw(), not here; this branch is intentionally left empty as a marker.
this.dormant = true;
If nutrients are too low, the seed stops updating forever (until, if ever, the soil beneath it improves) and just sits there.

Branch (class)

Branch is a lightweight helper class - each Plant is really just a growing list of these Branch segments. Separating branch logic from plant logic keeps the code organized: Branch only knows how to store and draw a single line segment.

class Branch {
  constructor(start, direction, length, thickness, depth) {
    this.start = start.copy();
    this.direction = direction.copy().normalize();
    this.length = length;
    this.thickness = thickness;
    this.depth = depth;
    this.end = p5.Vector.add(this.start, p5.Vector.mult(this.direction, this.length));
    this.active = true; // Can this branch grow further?
  }

  draw(hue, saturation, brightness) {
    stroke(hue, saturation, brightness);
    strokeWeight(this.thickness);
    line(this.start.x, this.start.y, this.end.x, this.end.y);
  }
}
Line-by-line explanation (4 lines)
this.direction = direction.copy().normalize();
Copies the given direction vector (so the original isn't mutated) and normalizes it to length 1, giving a pure 'compass heading'.
this.end = p5.Vector.add(this.start, p5.Vector.mult(this.direction, this.length));
Calculates where this branch's tip is by moving from its start point along its direction for 'length' units - this is basic vector math for line segments.
this.active = true; // Can this branch grow further?
Marks the branch as still eligible to sprout new child branches; the Plant class later sets this to false once a branch gets too short.
line(this.start.x, this.start.y, this.end.x, this.end.y);
Draws the branch as a simple straight line from its start point to its end point.

Plant (class)

Plant is the heart of the sketch's procedural generation: it keeps a growing array of Branch objects and, every frame, may extend a random still-active branch according to rules that differ by seedShape - giving triangles sharp fractal forks, squares blocky right-angle terraces, and pentagons/hexagons opposite-spiraling curves, all from the same underlying Branch/vector-rotation machinery.

🔬 This limits triangle-seed plants to 3 levels of branching and picks a fork angle up to ±45° (PI/4). What happens if you raise the depth limit to 6 for much more detailed fractals? What if you narrow the angle to PI/16 for straighter, less chaotic branches?

          if (parentBranch.depth < 3) { // Limit fractal depth
            let newLength = parentBranch.length * random(0.6, 0.8);
            let newThickness = parentBranch.thickness * random(0.6, 0.8);
            let angle1 = random(-PI / 4, PI / 4); // Random angle

🔬 This is what makes neighboring plants lean away from each other. What happens if you shrink the 0.75 multipliers to 0.25 so plants only react when very close? What happens if you swap .sub() for .add() so plants grow toward each other instead of away?

          if (distToOther < this.maxSize * 0.75 + otherPlant.maxSize * 0.75) { // Check if plants are close
            let dirToOther = createVector(otherPlant.x - this.x, otherPlant.y - this.y).normalize();
            avoidanceDirection.sub(dirToOther); // Push away from other plants
          }
class Plant {
  constructor(x, y, seedShape, germinationFrame) {
    this.x = x;
    this.y = y;
    this.seedShape = seedShape;
    this.germinationFrame = germinationFrame;
    this.age = 0;
    this.maxSize = random(40, 80);
    this.maxBranches = floor(random(25, 60));
    this.growthChance = random(0.1, 0.3);
    this.nutrientConsumptionRate = random(0.1, 0.5);
    this.hue;
    this.saturation = 80;
    this.brightness = 70;

    this.branches = [];

    switch (seedShape) {
      case 'triangle':
        this.hue = map(random(), 0, 1, 30, 50);
        break;
      case 'square':
        this.hue = map(random(), 0, 1, 45, 65);
        break;
      case 'pentagon':
      case 'hexagon':
        this.hue = map(random(), 0, 1, 180, 220);
        break;
    }

    let trunkLength = random(10, 20);
    let trunkThickness = random(2, 4);
    this.branches.push(new Branch(createVector(0, 0), createVector(0, -1), trunkLength, trunkThickness, 0));
  }

  update() {
    this.age++;

    let plantBaseX = floor(this.x / cellSize);
    let plantBaseY = floor((this.y - (height - soilHeight)) / cellSize);

    for (let rOffset = 0; rOffset < 3; rOffset++) {
      let cellR = plantBaseY - rOffset;
      let cellC = plantBaseX;
      if (cellR >= 0 && cellR < rows && cellC >= 0 && cellC < cols) {
        soilGrid[cellR][cellC] = constrain(soilGrid[cellR][cellC] - (this.nutrientConsumptionRate / 3), 0, 100);
      }
    }

    if (random() < this.growthChance && this.branches.length < this.maxBranches) {
      let activeBranches = this.branches.filter(b => b.active);
      if (activeBranches.length === 0) return;

      let parentBranch = random(activeBranches);

      let lightDirection = createVector(0, -1);
      let avoidanceDirection = createVector(0, 0);

      for (let otherPlant of plants) {
        if (otherPlant !== this) {
          let distToOther = dist(this.x, this.y, otherPlant.x, otherPlant.y);
          if (distToOther < this.maxSize * 0.75 + otherPlant.maxSize * 0.75) {
            let dirToOther = createVector(otherPlant.x - this.x, otherPlant.y - this.y).normalize();
            avoidanceDirection.sub(dirToOther);
          }
        }
      }
      avoidanceDirection.normalize();

      let baseDirection = parentBranch.direction.copy();
      baseDirection.add(p5.Vector.mult(lightDirection, 0.5));
      baseDirection.add(p5.Vector.mult(avoidanceDirection, 0.3));
      baseDirection.normalize();

      switch (this.seedShape) {
        case 'triangle':
          if (parentBranch.depth < 3) {
            let newLength = parentBranch.length * random(0.6, 0.8);
            let newThickness = parentBranch.thickness * random(0.6, 0.8);
            let angle1 = random(-PI / 4, PI / 4);
            let newDir1 = rotateVector(baseDirection, angle1);
            this.branches.push(new Branch(parentBranch.end, newDir1, newLength, newThickness, parentBranch.depth + 1));
            if (random() < 0.5) {
              let angle2 = random(-PI / 4, PI / 4);
              let newDir2 = rotateVector(baseDirection, angle2);
              this.branches.push(new Branch(parentBranch.end, newDir2, newLength, newThickness, parentBranch.depth + 1));
            }
          }
          break;

        case 'square':
          let newLengthSquare = parentBranch.length * random(0.8, 1.1);
          let newThicknessSquare = parentBranch.thickness * random(0.9, 1.1);
          let angleSquare = random([-PI / 2, PI / 2]);
          angleSquare += random(-PI / 12, PI / 12);
          let newDirSquare = rotateVector(baseDirection, angleSquare);
          this.branches.push(new Branch(parentBranch.end, newDirSquare, newLengthSquare, newThicknessSquare, parentBranch.depth + 1));
          break;

        case 'pentagon':
        case 'hexagon':
          let newLengthCurve = parentBranch.length * random(0.7, 0.9);
          let newThicknessCurve = parentBranch.thickness * random(0.7, 0.9);
          let spiralBias = (this.seedShape === 'pentagon' ? PI / 12 : -PI / 12);
          spiralBias += random(-PI / 24, PI / 24);
          let newDirCurve = rotateVector(baseDirection, spiralBias);
          this.branches.push(new Branch(parentBranch.end, newDirCurve, newLengthCurve, newThicknessCurve, parentBranch.depth + 1));
          if (random() < 0.3) {
            let newDirCurve2 = rotateVector(baseDirection, -spiralBias);
            this.branches.push(new Branch(parentBranch.end, newDirCurve2, newLengthCurve, newThicknessCurve, parentBranch.depth + 1));
          }
          break;
      }
      if (parentBranch.length < 5) parentBranch.active = false;
    }
  }

  draw() {
    push();
    translate(this.x, this.y);
    rotate(sin(frameCount * 0.02) * 0.1);

    for (let branch of this.branches) {
      branch.draw(this.hue, this.saturation, this.brightness);
    }
    pop();
  }
}
Line-by-line explanation (11 lines)

🔧 Subcomponents:

for-loop Nutrient Consumption Loop for (let rOffset = 0; rOffset < 3; rOffset++) {

Drains a small amount of nutrient from the soil cell the plant is rooted in and two cells above it, each frame.

conditional Growth Chance Gate if (random() < this.growthChance && this.branches.length < this.maxBranches) {

Rolls dice each frame to decide whether the plant grows a new branch this frame, capped by maxBranches.

for-loop Neighbor Avoidance Loop for (let otherPlant of plants) {

Checks every other plant and builds a direction vector pointing away from nearby plants so branches lean away from crowding neighbors.

switch-case Shape-Specific Growth Rules switch (this.seedShape) {

Applies a different branching pattern (angular fork, blocky terrace, or spiral curve) depending on which seed shape produced this plant.

this.maxBranches = floor(random(25, 60)); // Limit to prevent too many branches
Sets a random ceiling on how many branches this individual plant can ever grow, so plants vary in eventual bushiness.
this.hue = map(random(), 0, 1, 30, 50);
For triangle-seeded plants, picks a random warm yellow/orange hue that will color every branch of this plant.
this.branches.push(new Branch(createVector(0, 0), createVector(0, -1), trunkLength, trunkThickness, 0));
Creates the very first branch (the trunk), starting at the plant's local origin and pointing straight up (negative y direction), at depth 0.
soilGrid[cellR][cellC] = constrain(soilGrid[cellR][cellC] - (this.nutrientConsumptionRate / 3), 0, 100);
Every frame, the plant quietly eats a little nutrient from the soil cells right beneath and around its base, tying its growth to the living soil simulation.
if (random() < this.growthChance && this.branches.length < this.maxBranches) {
Growth is probabilistic, not guaranteed, every frame - growthChance controls roughly how many frames pass between new branches.
let parentBranch = random(activeBranches);
Picks a random still-growable branch to extend from, which is what gives each plant an irregular, organic branching pattern rather than a symmetric tree.
avoidanceDirection.sub(dirToOther);
Subtracts the direction toward a nearby plant, effectively building up a vector pointing away from all crowding neighbors combined.
if (parentBranch.depth < 3) {
Limits triangle-shaped plants to only 3 levels of forking so their fractal pattern doesn't grow infinitely fine.
let angleSquare = random([-PI / 2, PI / 2]);
Square-seeded plants only ever turn by exactly 90 degrees (left or right), which is what gives them their blocky, terraced look.
let spiralBias = (this.seedShape === 'pentagon' ? PI / 12 : -PI / 12);
Pentagon plants curve consistently one way and hexagon plants curve the other way, which is what makes them spiral in opposite directions.
if (parentBranch.length < 5) parentBranch.active = false;
Once a branch becomes very short, it's retired from future growth so the plant doesn't sprout branches from microscopic twigs forever.

📦 Key Variables

cellSize number

Pixel size of each square cell in the soil grid; controls grid resolution.

let cellSize = 10;
soilHeight number

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

let soilHeight = 150;
cols number

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

let cols;
rows number

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

let rows;
soilGrid array

2D array storing each cell's nutrient value (0-100); this is the core simulation state.

let soilGrid = [];
wells array

Array of nutrient well objects ({x, y, radius}) that regenerate nearby soil each frame.

let wells = [];
perlinNoiseOffset number

A slowly-increasing third coordinate fed into noise() to make the nutrient noise pattern drift over time.

let perlinNoiseOffset = 0;
numWells number

How many nutrient wells exist this run (randomized 5-8).

let numWells;
diffusionRate number

Fraction (0-1) controlling how strongly each cell blends toward its neighbors' average nutrient value.

let diffusionRate = 0.05;
regenerationRate number

Maximum nutrient boost per frame that a well can add to the cell right at its center.

let regenerationRate = 0.5;
wellRadiusCells number

How many grid cells out from its center a well's regenerating influence reaches.

let wellRadiusCells = 5;
noiseScale number

Zoom level for the Perlin noise sampled across the grid; smaller values create larger, smoother patches.

let noiseScale = 0.02;
noiseAmplitude number

Maximum nutrient points that Perlin noise can add or subtract from a cell.

let noiseAmplitude = 10;
seeds array

Holds all currently falling or dormant Seed objects.

let seeds = [];
plants array

Holds all currently growing Plant objects.

let plants = [];
seedSpawnTimer number

Frame counter tracking how long since the last seed was spawned.

let seedSpawnTimer = 0;
nextSeedSpawnTime number

Randomized number of frames to wait before spawning the next seed.

let nextSeedSpawnTime = 0;
gravity number

Downward acceleration applied to falling seeds each frame.

let gravity = 0.1;
windMagnitude number

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

let windMagnitude = 0.05;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

PERFORMANCE draw() - well regeneration loop

For every well, the code scans the ENTIRE grid (rows*cols cells) just to find the handful of cells within wellRadiusCells, making this O(numWells * rows * cols) every single frame even though most of the grid is outside any well's radius.

💡 Compute a bounding box around each well (wellCol ± wellRadiusCells, wellRow ± wellRadiusCells) and only loop over that smaller range of rows/columns instead of the whole grid.

BUG draw() - well regeneration vs diffusion comment

The diffusion section's comment claims newSoilGrid is computed from 'the state at the beginning of the frame', but the well-regeneration loop right above it already mutates soilGrid in place before diffusion reads from it - so diffusion actually sees soil values that already include this frame's well boost, not the true start-of-frame state.

💡 Either fold the well regeneration into the same single pass that builds newSoilGrid, or accumulate well regeneration into a separate delta grid and apply it after diffusion, so the frame's calculations are consistent with the comment's intent.

STYLE Seed.update()

The 'if (nutrientValue > 40) { // Germinate! ... }' block in Seed.update() is empty - all germination actually happens later in draw(), which recomputes the very same nutrientValue check redundantly, making the logic harder to follow.

💡 Either remove the empty if/else here and just leave a comment noting germination is handled in draw(), or set a simple 'this.readyToGerminate = true' flag here so draw() doesn't need to recheck the nutrient threshold.

PERFORMANCE Plant.update()

plantBaseX and plantBaseY are recalculated from this.x/this.y every single frame, even though a Plant never moves after it's created.

💡 Calculate plantBaseX/plantBaseY once in the constructor and store them as this.baseCol/this.baseRow, then reuse those cached values in update().

🔄 Code Flow

Code flow showing setup, draw, initsoilgridandwells, windowresized, drawpolygon, rotatevector, seed, branch, plant

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> wellregen[well-regen-loop] wellregen --> noise[noise-diffusion-loop] noise --> germination[germination-check] germination --> plantdeath[plant-death-check] plantdeath --> nutrient[nutrient-consumption-loop] nutrient --> growth[growth-gate] growth --> avoidance[avoidance-loop] avoidance --> draw click setup href "#fn-setup" click draw href "#fn-draw" click wellregen href "#sub-well-regen-loop" click noise href "#sub-noise-diffusion-loop" click germination href "#sub-germination-check" click plantdeath href "#sub-plant-death-check" click nutrient href "#sub-nutrient-consumption-loop" click growth href "#sub-growth-gate" click avoidance href "#sub-avoidance-loop" wellregen -->|for-loop| wellsinit[wells-init-loop] wellsinit -->|for-loop| gridinit[grid-init-loop] gridinit -->|for-loop| drawpolygon[drawpolygon] germination -->|conditional| seedguard[seed-dormant-guard] seedguard -->|conditional| seedcollision[seed-collision] seedcollision -->|switch-case| seedshape[seed-shape-switch] growth -->|conditional| shapegrowth[shape-growth-switch] shapegrowth -->|conditional| branchgrowth[Branch Growth] branchgrowth -->|for-loop| avoidloop[avoidance-loop] noise -->|for-loop| noiseupdate[Noise Update] noiseupdate -->|for-loop| diffusion[Diffusion Pass] click wellsinit href "#sub-wells-init-loop" click gridinit href "#sub-grid-init-loop" click drawpolygon href "#fn-drawpolygon" click seedguard href "#sub-seed-dormant-guard" click seedcollision href "#sub-seed-collision" click seedshape href "#sub-seed-shape-switch" click shapegrowth href "#sub-shape-growth-switch" click branchgrowth href "#sub-branch-growth" click avoidloop href "#sub-avoidance-loop" click noiseupdate href "#sub-noise-update" click diffusion href "#sub-diffusion-pass"

❓ Frequently Asked Questions

What visual effects can I expect from the FractalGarden sketch?

The FractalGarden sketch creates a mesmerizing visual display of drifting seeds, glowing soil, and branching plants that dynamically respond to hidden nutrient waves.

How can I interact with the FractalGarden sketch while using it?

Users can interact with the sketch by moving and resizing their browser window, which causes the ecosystem to re-form and adapt to the changing landscape.

What creative coding concepts are showcased in the FractalGarden sketch?

This sketch demonstrates techniques such as Perlin noise for simulating natural phenomena, diffusion for nutrient spreading, and L-system-inspired growth for plant development.

Preview

FractalGarden - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of FractalGarden - Code flow showing setup, draw, initsoilgridandwells, windowresized, drawpolygon, rotatevector, seed, branch, plant
Code Flow Diagram