Generate a Grass Field with L-Systems

Build a procedural animated grass field using L-systems and turtle graphics.

Introduction

30 Second Summary

Every blade of grass in a meadow looks slightly different, yet they all follow the same basic growth pattern. Computer scientists discovered that a few simple rules, applied repeatedly, can generate this kind of natural variety.

In this project, you will build a procedural grass field that grows in your browser. You will use L-systems (a type of formal grammar) to generate unique grass blades through stochastic rules and render them with turtle graphics in p5.js.

What You'll Build

A browser-based animated grass field where each blade is procedurally generated by an L-system engine with randomized production rules.

By the end of this project, you'll have:

  • A working L-system engine that expands axioms using stochastic production rules.
  • A turtle graphics renderer that interprets L-system strings into grass blade geometry using p5.js.
  • An animated grass field with 40+ unique blades that sprout from the ground with natural height, color, and shape variation.
  • Secret Mission: Add wind simulation that makes the grass sway dynamically in response to a sine-wave breeze.

Are there any prerequisites?

This project assumes basic familiarity with HTML and JavaScript (variables, functions, and loops). You do not need any prior experience with computer graphics or L-systems.

Set Up Your Project Files

Every procedural graphics project starts with a canvas. Before you can generate any grass blades, you need a drawing surface in the browser and a library to handle the rendering.

In this step, you'll create your project folder, set up an HTML page that loads p5.js (a creative coding library), and verify everything works with a simple colored canvas.

In this step, get ready to:
  • Create your project folder and files.
  • Load p5.js from a CDN and verify it renders a canvas.
Create your project folder and HTML file

Your project needs just two files: an HTML page to load the library, and a JavaScript file for your drawing code. Let's create the folder structure first.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open a terminal window.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir l-system-grass
cd l-system-grass
  • Create your two project files by running:
touch index.html sketch.js

On Windows and touch doesn't work?

If you're using PowerShell, run New-Item index.html, sketch.js instead. Alternatively, you can create the files from your text editor's sidebar in the next step.

Help me create files on Windows.

  • Confirm the files exist by running:
ls

You should see index.html and sketch.js in the output.

  • Now open your text editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or your preferred editor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the l-system-grass folder, and click Open.

You should see both files listed in the Explorer sidebar on the left.

  • Click index.html in the sidebar to open it.
  • Paste the following HTML code into the file:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>L-System Grass Field</title>
  <script src="https://cdn.jsdelivr.net/npm/p5@1.11.13/lib/p5.min.js"></script>
  <script src="sketch.js"></script>
  <style>
    body {
      margin: 0;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background: #1a1a2e;
    }
  </style>
</head>
<body>
</body>
</html>

What does this code do?

This HTML file has two key jobs:

  • The first <script> tag loads p5.js version 1.11.13 from a CDN (Content Delivery Network). This gives you access to all of p5.js's drawing functions without installing anything.
  • The second <script> tag links to sketch.js, where you'll write all of your drawing code.
  • The CSS centers the canvas on the page and sets a dark background so the canvas stands out.
  • Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Open index.html in your browser by double-clicking the file in Finder (macOS) or File Explorer (Windows), or by dragging it into your browser window.
  • You should see a blank page with a dark background and no console errors. Open DevTools by pressing F12 and check the Console tab to confirm there are no red error messages.

Seeing console errors?

  • If you see a 404 error for p5.min.js, check that the CDN URL in your <script> tag matches exactly: https://cdn.jsdelivr.net/npm/p5@1.11.13/lib/p5.min.js.
  • If you see a 404 for sketch.js, make sure both files are in the same folder.

Help me fix console errors when loading my HTML file.

Write the p5.js setup function

The blank page confirms p5.js loaded successfully. Now you'll add a drawing function to create your canvas.

How does p5.js work?

p5.js uses a two-function lifecycle. The setup() function runs once when the sketch starts and is where you create your canvas. The draw() function runs repeatedly (about 60 times per second) for animation.

For now, you only need setup() to confirm everything is wired up correctly.

  • Switch back to your text editor and click sketch.js in the sidebar to open it.
  • Add the following code to create a canvas with a sky-blue background:
function setup() {
  createCanvas(800, 600);
  background(135, 206, 235);
}

What does this code do?

  • createCanvas(800, 600) makes an 800-pixel-wide by 600-pixel-tall drawing surface in the browser.
  • background(135, 206, 235) fills the canvas with a sky-blue color using RGB values. This will be replaced with a gradient sky later.
  • Save sketch.js.
  • Switch back to your browser and refresh the page.
  • You should see a sky-blue rectangle (your 800x600 canvas) centered on the dark background.

Don't see the blue canvas?

  • Make sure you saved sketch.js before refreshing. Unsaved changes won't appear.
  • Check that your function is named exactly setup (all lowercase). p5.js looks for this exact name.
  • Open DevTools (F12) and check the Console for errors. A typo in the function name or missing parentheses will show up here.

Help me figure out why my p5.js canvas isn't showing.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here's what index.html should look like:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>L-System Grass Field</title>
  <script src="https://cdn.jsdelivr.net/npm/p5@1.11.13/lib/p5.min.js"></script>
  <script src="sketch.js"></script>
  <style>
    body {
      margin: 0;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background: #1a1a2e;
    }
  </style>
</head>
<body>
</body>
</html>

And here's what sketch.js should look like:

function setup() {
  createCanvas(800, 600);
  background(135, 206, 235);
}
Confirm your canvas renders

Your sky-blue canvas is the rendering surface for your entire grass field. Every blade of grass, the ground, and the sky gradient will all be drawn onto this 800x600 pixel rectangle.

Your rendering surface is ready. Next up, you'll build the L-system engine that generates the strings describing each grass blade's shape.

Build the L-System Engine

Your canvas is ready. Now you need something to draw on it.

An L-system (Lindenmayer system) generates complex structures by repeatedly rewriting a simple string using production rules. It is the same technique used to model plant growth in computer graphics. In this step, you will build the engine that rewrites strings according to randomized rules, producing a unique grass blade pattern every time.

In this step, get ready to:
  • Write a string-rewriting function that expands an axiom through multiple iterations.
  • Add stochastic (probability-based) rule selection so every expansion is unique.
  • Define grass-specific rules and verify the engine produces a valid L-system string.
Write the L-system expansion function

An L-system works by taking a starting string (the axiom) and replacing each character with a new string according to production rules. All replacements happen simultaneously across the entire string in each iteration. After several iterations, a short axiom like F expands into a long, complex instruction string.

  • In sketch.js, add the following two functions below your existing setup() function:
// --- L-System Engine ---

function expandLSystem(axiom, rules, iterations) {
  // Start with the axiom string
  let current = axiom;

  // Each iteration rewrites the entire string
  for (let n = 0; n < iterations; n++) {
    let next = "";

    for (let i = 0; i < current.length; i++) {
      let ch = current[i];

      // If a rule exists for this character, apply it
      if (rules[ch]) {
        next += pickStochasticRule(rules[ch]);
      } else {
        // Keep characters that have no rule
        next += ch;
      }
    }

    current = next;
  }

  return current;
}

function pickStochasticRule(ruleArray) {
  // Pick a random number between 0 and 1
  let r = Math.random();
  let cumulative = 0;

  // Walk through rules, accumulating probability
  for (let i = 0; i < ruleArray.length; i++) {
    cumulative += ruleArray[i].probability;
    if (r < cumulative) {
      return ruleArray[i].replacement;
    }
  }

  // Fallback to last rule if floating-point rounding misses
  return ruleArray[ruleArray.length - 1].replacement;
}

What does this code do?

expandLSystem() loops through the string once per iteration. On each pass, every character is checked against the rules object. If a matching rule exists, that character is replaced. If not, it stays unchanged.

pickStochasticRule() selects a replacement string from an array of options based on their probability weights. It generates a random number and walks through the options until the cumulative probability exceeds that number. This means each expansion can produce a different result.

You won't see a change in the browser yet. The next chunk defines the rules that these functions will use.

Define stochastic rules for grass

A deterministic L-system applies the same replacement every time, producing identical output. Stochastic rules assign multiple possible replacements to a single symbol, each with a probability. This creates natural variation between grass blades.

  • In sketch.js, add the following constants and rules definition above your setup() function. These go at the very top of the file:
// Project-wide configuration constants
const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const BLADE_COUNT = 50;
const DRAW_SPEED = 2;
const L_ITERATIONS = 4;
const L_ANGLE = 22;

// Stochastic production rules for grass blades
// F can become one of four patterns, chosen by probability
const grassRules = {
  F: [
    { replacement: "FF", probability: 0.3 },
    { replacement: "F[+F]F", probability: 0.25 },
    { replacement: "F[-F]F", probability: 0.25 },
    { replacement: "F[+F][-F]F", probability: 0.2 }
  ]
};

What do these rules mean?

Each rule replaces the symbol F (draw forward) with a pattern of turtle graphics commands. + turns clockwise, - turns counter-clockwise, [ saves the current position, and ] returns to the saved position.

The four probabilities sum to 1.0. FF simply extends the blade. F[+F]F and F[-F]F add small side branches (tillers). F[+F][-F]F adds branches on both sides.

You won't see output yet. The next chunk updates setup() to test the engine.

Test the engine with a console log

Now you will connect everything by updating setup() to use the new constants, set the color mode for later use, and expand a grass blade string to verify the engine works.

  • In sketch.js, find your existing setup() function:
function setup() {
  createCanvas(800, 600);
  background(135, 206, 235);
}
  • Replace the entire setup() function with this updated version:
function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  // HSB color mode makes it easy to create natural green variations later
  colorMode(HSB, 360, 100, 100, 100);

  // Test the L-system engine by expanding a grass blade
  console.log(expandLSystem("F", grassRules, L_ITERATIONS));
}

What does this code do?

createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT) uses the constants you defined at the top instead of hardcoded numbers.

colorMode(HSB, 360, 100, 100, 100) switches p5.js to Hue-Saturation-Brightness color mode. This makes it easier to create natural green variations later by just tweaking the hue value.

The console.log call expands the axiom F through 4 iterations of the grass rules, printing the resulting instruction string to your browser console.

  • Save sketch.js.
  • Refresh your browser and open the developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • You should see a long string of F, +, -, [, and ] characters in the console. Refresh a few times and notice the string changes each time.

Seeing an error in the console?

  • If you see a reference error mentioning pickStochasticRule, make sure that function is defined below expandLSystem in your file.
  • If you see a reference error mentioning grassRules, check that the constants and grassRules object are defined above setup() at the top of the file.
  • If you see nothing logged at all, confirm you removed the old background(135, 206, 235) line and replaced setup() with the new version that includes the console.log call.

Help me debug my L-system engine in p5.js

Why does the string change every refresh?

Because your rules are stochastic. Each time expandLSystem encounters an F, it rolls a random number to choose one of four replacements. Over 4 iterations, those random choices compound into a unique string every time. This is how you will get a field of grass where no two blades look the same.

✔️ Awesome, I've got everything!

Great. Double check you have saved sketch.js.

ⓧ I'd like to double check the full code

Here is the full sketch.js file at the end of this step:

const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const BLADE_COUNT = 50;
const DRAW_SPEED = 2;
const L_ITERATIONS = 4;
const L_ANGLE = 22;

const grassRules = {
  F: [
    { replacement: "FF", probability: 0.3 },
    { replacement: "F[+F]F", probability: 0.25 },
    { replacement: "F[-F]F", probability: 0.25 },
    { replacement: "F[+F][-F]F", probability: 0.2 }
  ]
};

function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  console.log(expandLSystem("F", grassRules, L_ITERATIONS));
}

function expandLSystem(axiom, rules, iterations) {
  let current = axiom;
  for (let n = 0; n < iterations; n++) {
    let next = "";
    for (let i = 0; i < current.length; i++) {
      let ch = current[i];
      if (rules[ch]) {
        next += pickStochasticRule(rules[ch]);
      } else {
        next += ch;
      }
    }
    current = next;
  }
  return current;
}

function pickStochasticRule(ruleArray) {
  let r = Math.random();
  let cumulative = 0;
  for (let i = 0; i < ruleArray.length; i++) {
    cumulative += ruleArray[i].probability;
    if (r < cumulative) {
      return ruleArray[i].replacement;
    }
  }
  return ruleArray[ruleArray.length - 1].replacement;
}

Your L-system engine is working. It can expand a single F into a long instruction string full of drawing commands. Next up, you will turn that string into visible grass blades using turtle graphics.

Render Grass Blades with Turtle Graphics

Your L-system engine produces strings of characters like F[+F]F[-F]F. Those strings encode growth instructions, but they don't draw anything on their own. You need an interpreter that reads each character and moves a virtual pen across the canvas.

That interpreter is called a turtle graphics engine. In this step, you'll build one that walks through an L-system string symbol by symbol, turning those abstract characters into visible line segments on screen.

In this step, get ready to:
  • Build a turtle graphics interpreter that converts L-system strings into line segment data.
  • Write a rendering function that draws segments with natural thickness tapering.
  • Render a single test blade to verify the full pipeline from string to screen.
Write the turtle graphics interpreter

Turtle graphics works like giving directions to a tiny robot holding a pen. The robot starts facing upward and follows one-character commands: F means walk forward and draw a line, + means turn clockwise, - means turn counter-clockwise, [ means save your current position, and ] means jump back to the last saved position.

  • In sketch.js, add the buildSegments function below your pickStochasticRule function:
function buildSegments(str, angleDeg, stepLen) {
  // Store all line segments the turtle draws
  let segments = [];
  // Stack for saving/restoring turtle position at branches
  let stack = [];
  let x = 0, y = 0;
  // Start pointing upward (-90 degrees in radians)
  let heading = -Math.PI / 2;
  // Convert angle to radians for trig functions
  let rad = (angleDeg * Math.PI) / 180;
  // Track branching depth for thickness tapering
  let depth = 0;
  for (let i = 0; i < str.length; i++) {
    let c = str[i];
    if (c === "F") {
      // Move forward and record the line segment
      let nx = x + stepLen * Math.cos(heading);
      let ny = y + stepLen * Math.sin(heading);
      segments.push({ x1: x, y1: y, x2: nx, y2: ny, depth: depth });
      x = nx; y = ny;
    } else if (c === "+") {
      heading += rad;
    } else if (c === "-") {
      heading -= rad;
    } else if (c === "[") {
      // Save turtle state before branching
      stack.push({ x, y, heading });
      depth++;
    } else if (c === "]") {
      // Restore turtle state to return from branch
      let st = stack.pop();
      if (st) { x = st.x; y = st.y; heading = st.heading; }
      depth--;
    }
  }
  return segments;
}

What does this code do?

This function walks through every character in the L-system string and simulates a pen moving on paper. It doesn't draw anything yet. It just calculates where each line segment would go and stores the coordinates in an array.

  • heading = -Math.PI / 2 starts the turtle pointing straight up (negative Y is up in canvas coordinates).
  • The stack array lets the turtle save its position before a branch ([) and jump back (]). This is how branches grow from the main stalk.
  • The depth counter tracks how deep into branches we are. You'll use this next to make branch tips thinner than the base.
  • Save your file. You won't see a visual change yet because the function only computes segment data without drawing.

Seeing a syntax error in the console?

  • Make sure the function is placed after the closing brace of pickStochasticRule, not inside it.
  • Check that all curly braces are balanced. The function has a for-loop containing multiple if/else blocks.

Help me debug my buildSegments function.

Write the blade rendering function with thickness tapering

Real grass is thicker at the base and tapers to a thin tip. You can simulate this by making the stroke weight thinner as the depth increases (further out on a branch means thinner lines).

  • Add the drawBladeSegments function below buildSegments:
function drawBladeSegments(blade, count) {
  let maxDepth = 3;
  for (let i = 0; i < count; i++) {
    let seg = blade.segments[i];
    // Thicker at base (depth 0), thinner at branch tips
    let thickness = map(seg.depth, 0, maxDepth, 2.5, 0.5);
    // Gradually brighten toward the tip
    let briShift = map(i, 0, blade.segments.length, 0, 15);
    strokeWeight(thickness);
    stroke(blade.hue, blade.sat, blade.bri + briShift, 90);
    line(blade.x + seg.x1, blade.y + seg.y1, blade.x + seg.x2, blade.y + seg.y2);
  }
}

What does this code do?

  • map(seg.depth, 0, maxDepth, 2.5, 0.5) converts the branch depth into a stroke weight. Depth 0 (main stalk) gets 2.5px thickness. Depth 3 (outermost branches) gets 0.5px.
  • briShift adds a subtle brightness increase from base to tip. This simulates light catching the thinner upper parts of the grass blade.
  • Each segment is drawn at the blade's base position (blade.x, blade.y) plus the relative turtle coordinates. This means you can place blades anywhere on the canvas.
  • Save your file. You still won't see a visual change because nothing calls this function yet.

Console showing an error about map?

  • map() is a p5.js function. If you see an error about it, make sure your index.html still loads the p5.js script before sketch.js.
  • Check that the function is outside of any other function. It should be at the top level of your file, not nested inside setup() or buildSegments().

Help me fix my drawBladeSegments function.

Draw a single test blade at the bottom of the canvas

Time to connect the pieces. You'll update your setup() function to generate one L-system string, convert it to segments, and draw a single blade at the bottom-center of the canvas.

  • In sketch.js, find your existing setup() function:
function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  console.log(expandLSystem("F", grassRules, L_ITERATIONS));
}
  • Replace the entire setup() function with this updated version:
function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  background(200, 40, 90);
  // Generate one test blade's L-system string
  let lStr = expandLSystem("F", grassRules, L_ITERATIONS);
  // Convert the string into drawable segments
  let segs = buildSegments(lStr, L_ANGLE, 5);
  // Create a blade object positioned at bottom-center
  let testBlade = { x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT - 40, segments: segs, hue: 110, sat: 70, bri: 60 };
  // Draw all segments of the test blade
  drawBladeSegments(testBlade, segs.length);
}

What does this code do?

  • background(200, 40, 90) fills the canvas with a sky blue so you can see the green blade against it.
  • The step length of 5 pixels makes each segment short enough that 4 iterations of branching still fit on screen.
  • The testBlade object bundles position, color, and segment data together. This is the same shape you'll use for every blade in the full field.
  • Save sketch.js and refresh your browser.
  • You should see a single green grass blade growing upward from the bottom-center of the canvas. It will have natural-looking curves and visible thickness tapering from thick at the base to thin at the tips.

Why does the blade look different each refresh?

Your stochastic rules from the previous step are at work. Each time the page loads, the L-system expands with different random choices, producing a unique blade shape. Refresh a few times to see the variety.

Don't see a grass blade?

  • Open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and check for error messages.
  • If you see a blue canvas but no blade, make sure buildSegments and drawBladeSegments are defined outside of setup(), not inside it.
  • If you see nothing at all, confirm that index.html loads sketch.js after the p5.js CDN script.

Help me debug my grass blade rendering.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the full sketch.js at this point:

const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const BLADE_COUNT = 50;
const DRAW_SPEED = 2;
const L_ITERATIONS = 4;
const L_ANGLE = 22;

const grassRules = {
  F: [
    { replacement: "FF", probability: 0.3 },
    { replacement: "F[+F]F", probability: 0.25 },
    { replacement: "F[-F]F", probability: 0.25 },
    { replacement: "F[+F][-F]F", probability: 0.2 }
  ]
};

function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  background(200, 40, 90);
  let lStr = expandLSystem("F", grassRules, L_ITERATIONS);
  let segs = buildSegments(lStr, L_ANGLE, 5);
  let testBlade = { x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT - 40, segments: segs, hue: 110, sat: 70, bri: 60 };
  drawBladeSegments(testBlade, segs.length);
}

function expandLSystem(axiom, rules, iterations) {
  let current = axiom;
  for (let n = 0; n < iterations; n++) {
    let next = "";
    for (let i = 0; i < current.length; i++) {
      let ch = current[i];
      if (rules[ch]) {
        next += pickStochasticRule(rules[ch]);
      } else {
        next += ch;
      }
    }
    current = next;
  }
  return current;
}

function pickStochasticRule(ruleArray) {
  let r = Math.random();
  let cumulative = 0;
  for (let i = 0; i < ruleArray.length; i++) {
    cumulative += ruleArray[i].probability;
    if (r < cumulative) {
      return ruleArray[i].replacement;
    }
  }
  return ruleArray[ruleArray.length - 1].replacement;
}

function buildSegments(str, angleDeg, stepLen) {
  let segments = [];
  let stack = [];
  let x = 0, y = 0;
  let heading = -Math.PI / 2;
  let rad = (angleDeg * Math.PI) / 180;
  let depth = 0;
  for (let i = 0; i < str.length; i++) {
    let c = str[i];
    if (c === "F") {
      let nx = x + stepLen * Math.cos(heading);
      let ny = y + stepLen * Math.sin(heading);
      segments.push({ x1: x, y1: y, x2: nx, y2: ny, depth: depth });
      x = nx; y = ny;
    } else if (c === "+") {
      heading += rad;
    } else if (c === "-") {
      heading -= rad;
    } else if (c === "[") {
      stack.push({ x, y, heading });
      depth++;
    } else if (c === "]") {
      let st = stack.pop();
      if (st) { x = st.x; y = st.y; heading = st.heading; }
      depth--;
    }
  }
  return segments;
}

function drawBladeSegments(blade, count) {
  let maxDepth = 3;
  for (let i = 0; i < count; i++) {
    let seg = blade.segments[i];
    let thickness = map(seg.depth, 0, maxDepth, 2.5, 0.5);
    let briShift = map(i, 0, blade.segments.length, 0, 15);
    strokeWeight(thickness);
    stroke(blade.hue, blade.sat, blade.bri + briShift, 90);
    line(blade.x + seg.x1, blade.y + seg.y1, blade.x + seg.x2, blade.y + seg.y2);
  }
}

You now have a complete pipeline from L-system grammar to visible geometry. One blade is already looking natural with its curves and tapering thickness. Next, you'll scale this up to an entire field of unique blades with randomized colors, heights, and positions.

Generate the Full Grass Field

In the previous step, you rendered a single grass blade using turtle graphics. One blade proves the system works, but nature is about variety and abundance.

In this step, you'll scale from one blade to a full meadow of 50 unique grass blades. Each blade gets randomized position, size, and color. You'll also paint a sky gradient and ground beneath them to complete the scene.

In this step, get ready to:
  • Write a field generator that creates 50 unique grass blades with randomized properties.
  • Wire up the p5.js draw loop to render the full field every frame.
  • Add a gradient sky and ground to complete the scene.
Write the generateField() function

Right now your sketch creates one hardcoded test blade. To produce a full meadow, you need a function that generates many blades, each with randomized properties so no two look the same.

  • In sketch.js, find the closing brace of the grassRules object (the }; line before function setup()).
  • Add a global blades array declaration below it by pasting this code:
let blades = [];
  • Now scroll down past the buildSegments() function and add the generateField() function by pasting this code below it:
function generateField(count) {
  blades = [];

  for (let i = 0; i < count; i++) {
    // Randomize position across the canvas
    let xPos = random(20, CANVAS_WIDTH - 20);
    let yOffset = random(0, 30);

    // Randomize blade size and color
    let stepLen = random(3, 7);
    let hue = random(80, 140);
    let sat = random(50, 90);
    let bri = random(40, 80);
    let startDelay = floor(random(0, 60));

    // Generate a unique L-system string and precompute segments
    let lString = expandLSystem("F", grassRules, L_ITERATIONS);
    let segments = buildSegments(lString, L_ANGLE, stepLen);

    blades.push({
      x: xPos,
      y: CANVAS_HEIGHT - 40 - yOffset,
      segments: segments,
      segIndex: 0,
      hue: hue,
      sat: sat,
      bri: bri,
      stepLen: stepLen,
      startDelay: startDelay
    });
  }

  // Sort by y position so farther blades draw first
  blades.sort((a, b) => a.y - b.y);
}

What does this code do?

The generateField() function creates a full meadow of unique blades by randomizing each one's properties.

  • xPos scatters blades left-to-right across the canvas, while yOffset gives slight vertical variation to simulate uneven terrain.
  • stepLen controls blade height. Shorter step lengths produce shorter blades that appear farther away.
  • Hue, saturation, and brightness create natural green variations using HSB color mode.
  • The blades.sort() call draws "farther" blades (smaller y values) first, so closer blades overlap them naturally.

Why pre-compute segments?

Each blade's L-system string is expanded and converted to line segments once during generation, then stored in the segments array. This avoids re-running the string expansion every frame, keeping the draw loop fast.

Replace setup() and add draw() to render the field

Your current setup() function draws a single test blade. You need to replace it with a version that calls generateField(), and add a draw() function that renders all blades every frame.

  • In sketch.js, find your existing setup() function:
function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  background(200, 40, 90);
  let lStr = expandLSystem("F", grassRules, L_ITERATIONS);
  let segs = buildSegments(lStr, L_ANGLE, 5);
  let testBlade = { x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT - 40, segments: segs, hue: 110, sat: 70, bri: 60 };
  drawBladeSegments(testBlade, segs.length);
}
  • Replace the entire setup() function with this new version, and add the draw() function directly below it:
function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  generateField(BLADE_COUNT);
}

function draw() {
  // Redraw the scene each frame
  drawSky();
  drawGround();

  // Render every blade in the field
  for (let i = 0; i < blades.length; i++) {
    drawBladeSegments(blades[i], blades[i].segments.length);
  }
}

What does this code do?

  • setup() now calls generateField(BLADE_COUNT) to create all 50 blades once at startup.
  • draw() runs at ~60fps. Each frame it redraws the sky, ground, and all blades. This prepares us for animation in the next step.

Don't save yet. The draw() function calls drawSky() and drawGround() which don't exist yet. You'll add those next.

Add the sky gradient and ground

A field of floating green lines needs context to look like a meadow. A gradient sky and a solid ground strip anchor the blades visually.

  • Add the drawSky() and drawGround() functions at the bottom of sketch.js by pasting this code:
function drawSky() {
  noStroke();
  // Light blue at top, pale yellow near horizon
  let topColor = color(200, 40, 90);
  let bottomColor = color(50, 20, 95);

  // Draw one horizontal line per pixel row, blending between colors
  for (let y = 0; y < CANVAS_HEIGHT - 40; y++) {
    let inter = map(y, 0, CANVAS_HEIGHT - 40, 0, 1);
    let c = lerpColor(topColor, bottomColor, inter);
    stroke(c);
    line(0, y, CANVAS_WIDTH, y);
  }
}

function drawGround() {
  noStroke();
  // Dark green-brown soil strip at bottom
  fill(90, 60, 25);
  rect(0, CANVAS_HEIGHT - 40, CANVAS_WIDTH, 40);
}

What does this code do?

  • drawSky() uses lerpColor() to blend between a light blue (HSB 200, 40, 90) at the top and a pale yellow (HSB 50, 20, 95) near the horizon. It draws one colored line per pixel row to create a smooth gradient.
  • drawGround() draws a 40-pixel-tall dark rectangle at the bottom using HSB (90, 60, 25), which produces an earthy green-brown soil color.
  • Save sketch.js and refresh your browser.
  • You should see a full field of green grass blades covering the canvas, rooted in dark soil with a gradient sky behind them. Every blade has a unique shape, height, and shade of green.
  • Refresh again. The field should look different each time because the stochastic rules generate new blade shapes on every page load.

Seeing a blank canvas or errors?

  • Open your browser's developer console (F12 or Cmd+Option+J). If you see "drawSky is not defined", make sure you added both drawSky() and drawGround() functions in sketch.js.
  • If you see blades but no sky or ground, check that your draw() function calls drawSky() and drawGround() before the blade-drawing loop.
  • If all blades look identical, confirm you removed the old test blade code from setup() and replaced it with the generateField(BLADE_COUNT) call.

Why isn't my grass field rendering in the browser?

✔️ Awesome, I've got everything!

Great. Double check you've saved sketch.js and your field looks lush.

ⓧ I'd like to double check the full code

Here's the complete sketch.js at the end of this step. Compare yours to this:

const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const BLADE_COUNT = 50;
const DRAW_SPEED = 2;
const L_ITERATIONS = 4;
const L_ANGLE = 22;

const grassRules = {
  F: [
    { replacement: "FF", probability: 0.3 },
    { replacement: "F[+F]F", probability: 0.25 },
    { replacement: "F[-F]F", probability: 0.25 },
    { replacement: "F[+F][-F]F", probability: 0.2 }
  ]
};

let blades = [];

function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  generateField(BLADE_COUNT);
}

function draw() {
  drawSky();
  drawGround();
  for (let i = 0; i < blades.length; i++) {
    drawBladeSegments(blades[i], blades[i].segments.length);
  }
}

function expandLSystem(axiom, rules, iterations) {
  let current = axiom;
  for (let n = 0; n < iterations; n++) {
    let next = "";
    for (let i = 0; i < current.length; i++) {
      let ch = current[i];
      if (rules[ch]) { next += pickStochasticRule(rules[ch]); } else { next += ch; }
    }
    current = next;
  }
  return current;
}

function pickStochasticRule(ruleArray) {
  let r = Math.random();
  let cumulative = 0;
  for (let i = 0; i < ruleArray.length; i++) {
    cumulative += ruleArray[i].probability;
    if (r < cumulative) return ruleArray[i].replacement;
  }
  return ruleArray[ruleArray.length - 1].replacement;
}

function buildSegments(str, angleDeg, stepLen) {
  let segments = [];
  let stack = [];
  let x = 0, y = 0;
  let heading = -Math.PI / 2;
  let rad = (angleDeg * Math.PI) / 180;
  let depth = 0;
  for (let i = 0; i < str.length; i++) {
    let c = str[i];
    if (c === "F") {
      let nx = x + stepLen * Math.cos(heading);
      let ny = y + stepLen * Math.sin(heading);
      segments.push({ x1: x, y1: y, x2: nx, y2: ny, depth: depth });
      x = nx; y = ny;
    } else if (c === "+") { heading += rad;
    } else if (c === "-") { heading -= rad;
    } else if (c === "[") { stack.push({ x, y, heading }); depth++;
    } else if (c === "]") {
      let st = stack.pop();
      if (st) { x = st.x; y = st.y; heading = st.heading; }
      depth--;
    }
  }
  return segments;
}

function generateField(count) {
  blades = [];
  for (let i = 0; i < count; i++) {
    let xPos = random(20, CANVAS_WIDTH - 20);
    let yOffset = random(0, 30);
    let stepLen = random(3, 7);
    let hue = random(80, 140);
    let sat = random(50, 90);
    let bri = random(40, 80);
    let startDelay = floor(random(0, 60));
    let lString = expandLSystem("F", grassRules, L_ITERATIONS);
    let segments = buildSegments(lString, L_ANGLE, stepLen);
    blades.push({ x: xPos, y: CANVAS_HEIGHT - 40 - yOffset, segments: segments, segIndex: 0, hue: hue, sat: sat, bri: bri, stepLen: stepLen, startDelay: startDelay });
  }
  blades.sort((a, b) => a.y - b.y);
}

function drawBladeSegments(blade, count) {
  let maxDepth = 3;
  for (let i = 0; i < count; i++) {
    let seg = blade.segments[i];
    let thickness = map(seg.depth, 0, maxDepth, 2.5, 0.5);
    let briShift = map(i, 0, blade.segments.length, 0, 15);
    strokeWeight(thickness);
    stroke(blade.hue, blade.sat, blade.bri + briShift, 90);
    line(blade.x + seg.x1, blade.y + seg.y1, blade.x + seg.x2, blade.y + seg.y2);
  }
}

function drawSky() {
  noStroke();
  let topColor = color(200, 40, 90);
  let bottomColor = color(50, 20, 95);
  for (let y = 0; y < CANVAS_HEIGHT - 40; y++) {
    let inter = map(y, 0, CANVAS_HEIGHT - 40, 0, 1);
    let c = lerpColor(topColor, bottomColor, inter);
    stroke(c);
    line(0, y, CANVAS_WIDTH, y);
  }
}

function drawGround() {
  noStroke();
  fill(90, 60, 25);
  rect(0, CANVAS_HEIGHT - 40, CANVAS_WIDTH, 40);
}

Your meadow is alive with variety. Every refresh produces a completely new field. Next up, you'll animate the growth so blades sprout from the ground one by one instead of appearing all at once.

Animate the Growth

Your grass field renders instantly right now. Every blade appears fully formed the moment you refresh the page. Real grass doesn't work that way.

In this step, you'll bring the field to life by making each blade grow progressively from the ground up. You'll stagger when each blade starts sprouting, so the field fills in naturally rather than all at once.

In this step, get ready to:
  • Replace the draw loop with progressive segment rendering.
  • Stagger blade start times for a natural sprouting effect.
  • Add a regenerate button that creates infinite unique fields.
Replace the draw loop with progressive animation

Right now, your draw() function renders every segment of every blade on every frame. To animate growth, you'll track how many segments have been revealed so far and increment that count each frame.

  • In sketch.js, find your current draw() function and replace it with this animated version:
function draw() {
  drawSky();
  drawGround();

  for (let i = 0; i < blades.length; i++) {
    let blade = blades[i];
    // Only draw segments up to the current segIndex
    let segsToDraw = Math.min(blade.segIndex, blade.segments.length);
    drawBladeSegments(blade, segsToDraw);

    // Increment segIndex each frame until the blade is fully grown
    if (blade.segIndex < blade.segments.length) {
      blade.segIndex += DRAW_SPEED;
    }
  }
}

What does this code do?

Each blade already has a segIndex property (set to 0 in generateField()). This counter tracks how many segments have been "revealed" so far.

Each frame, segIndex grows by DRAW_SPEED (2 segments per frame). The blade progressively reveals itself from base to tip, creating the illusion of growth.

  • Save sketch.js and refresh your browser.
  • You should see all 50 blades growing upward simultaneously from the ground. The animation plays quickly (about 1-2 seconds) because every blade starts at the same time.

Blades not growing?

If the field still appears instantly, make sure you replaced the entire old draw() function. Having two draw() definitions means p5.js uses the last one it finds.

Also check that segIndex: 0 is part of each blade object in your generateField() function. Without it, the counter starts as undefined and the animation logic breaks.

Help me debug why my grass blades aren't animating

Stagger the blade start times

All blades sprouting at the exact same moment looks unnatural. Each blade object already has a startDelay property (a random value between 0 and 60 frames). You'll use this to make each blade wait before it starts growing.

  • In your draw() function, find this line inside the for loop:
    let blade = blades[i];
  • Add this line directly below it, so blades skip their turn until the frame count passes their delay:
    if (frameCount < blade.startDelay) continue;

How does the stagger work?

p5.js increments frameCount automatically on every frame (starting from 0). Each blade has a random startDelay between 0 and 60.

The continue statement skips a blade entirely until enough frames have passed. At 60fps, a 60-frame delay means blades sprout over about one second, creating a wave-like emergence across the field.

  • Save sketch.js and refresh your browser.
  • You should see blades sprouting at different times across the field, creating a natural "popping up" effect rather than all growing in unison.

All blades still starting together?

Make sure the if (frameCount < blade.startDelay) continue; line is inside the for loop, directly after let blade = blades[i]; and before the Math.min line.

Help me fix the stagger timing in my grass animation

Stop the animation and add a regenerate button

Once every blade is fully grown, there's no reason to keep redrawing the same frame 60 times per second. You'll detect when growth is complete, stop the loop, and offer a button to replay with a completely new random field.

  • In sketch.js, find this line near the top of your file:
let blades = [];
  • Add these two lines directly below it to track whether the animation is done and hold a reference to the button:
let allGrown = false;
let regenButton;

What are these variables for?

The allGrown boolean prevents the completion logic from firing more than once. Without it, the code would try to create a new button every frame after growth finishes.

The regenButton holds a reference to the DOM button so you can remove it later when the field regenerates.

  • Now replace your entire draw() function with this final version that includes completion detection:
function draw() {
  drawSky();
  drawGround();

  // Track how many blades have finished growing
  let grownCount = 0;

  for (let i = 0; i < blades.length; i++) {
    let blade = blades[i];
    if (frameCount < blade.startDelay) continue;
    let segsToDraw = Math.min(blade.segIndex, blade.segments.length);
    drawBladeSegments(blade, segsToDraw);
    if (blade.segIndex < blade.segments.length) {
      blade.segIndex += DRAW_SPEED;
    } else {
      grownCount++;
    }
  }

  // When all blades are fully grown, stop and show the button
  if (grownCount >= blades.length && !allGrown) {
    allGrown = true;
    noLoop();
    regenButton = createButton("Regenerate");
    regenButton.position(width / 2 - 40, height + 10);
    regenButton.mousePressed(regenerate);
  }
}

What's new in this version?

  • grownCount tracks how many blades have reached their full segment count each frame.
  • When grownCount equals the total blade count, noLoop() stops p5.js from calling draw() again.
  • createButton("Regenerate") creates a DOM button below the canvas that the viewer can click to replay.
  • Next, find your generateField() function. Locate this line at the top of the function:
  blades = [];
  • Add allGrown = false; directly below it, so the line reads:
  blades = [];
  allGrown = false;
  • Finally, add the regenerate() function at the very bottom of sketch.js:
function regenerate() {
  // Remove the old button and reset the field
  if (regenButton) { regenButton.remove(); regenButton = null; }
  generateField(BLADE_COUNT);
  loop();
}

What does regenerate() do?

It removes the existing button from the DOM, generates a brand new set of random blades (each with fresh stochastic L-system strings), and calls loop() to restart the p5.js draw cycle. The animation plays again from scratch with a completely different field.

  • Save sketch.js and refresh your browser.
  • Watch the grass grow with staggered timing. After all blades are fully drawn, a Regenerate button should appear below the canvas.
  • Click Regenerate to confirm the field re-randomizes and replays the growth animation.

Button not appearing?

If the animation runs but no button appears, check that you added allGrown = false; inside generateField(). Without it, allGrown stays true from a previous run and the completion block never fires.

If the button appears but clicking it does nothing, verify that regenerate() calls loop() at the end. This restarts the p5.js draw cycle after noLoop() stopped it.

Help me fix the regenerate button in my p5.js grass field

Add top-of-file comments

While you're wrapping up, add these two comment lines at the very top of sketch.js (above the constants) to document what the file does:

// L-System Grass Field
// Generates a procedural grass field using stochastic L-systems and turtle graphics

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and the animation plays correctly on refresh.

ⓧ I'd like to double check the full code

Here's the complete sketch.js at this point:

// L-System Grass Field
// Generates a procedural grass field using stochastic L-systems and turtle graphics

const CANVAS_WIDTH = 800;
const CANVAS_HEIGHT = 600;
const BLADE_COUNT = 50;
const DRAW_SPEED = 2;
const L_ITERATIONS = 4;
const L_ANGLE = 22;

const grassRules = {
  F: [
    { replacement: "FF", probability: 0.3 },
    { replacement: "F[+F]F", probability: 0.25 },
    { replacement: "F[-F]F", probability: 0.25 },
    { replacement: "F[+F][-F]F", probability: 0.2 }
  ]
};

let blades = [];
let allGrown = false;
let regenButton;

function setup() {
  createCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
  colorMode(HSB, 360, 100, 100, 100);
  generateField(BLADE_COUNT);
}

function draw() {
  drawSky();
  drawGround();

  let grownCount = 0;

  for (let i = 0; i < blades.length; i++) {
    let blade = blades[i];
    if (frameCount < blade.startDelay) continue;
    let segsToDraw = Math.min(blade.segIndex, blade.segments.length);
    drawBladeSegments(blade, segsToDraw);
    if (blade.segIndex < blade.segments.length) {
      blade.segIndex += DRAW_SPEED;
    } else {
      grownCount++;
    }
  }

  if (grownCount >= blades.length && !allGrown) {
    allGrown = true;
    noLoop();
    regenButton = createButton("Regenerate");
    regenButton.position(width / 2 - 40, height + 10);
    regenButton.mousePressed(regenerate);
  }
}

function expandLSystem(axiom, rules, iterations) {
  let current = axiom;
  for (let n = 0; n < iterations; n++) {
    let next = "";
    for (let i = 0; i < current.length; i++) {
      let ch = current[i];
      if (rules[ch]) { next += pickStochasticRule(rules[ch]); } else { next += ch; }
    }
    current = next;
  }
  return current;
}

function pickStochasticRule(ruleArray) {
  let r = Math.random();
  let cumulative = 0;
  for (let i = 0; i < ruleArray.length; i++) {
    cumulative += ruleArray[i].probability;
    if (r < cumulative) return ruleArray[i].replacement;
  }
  return ruleArray[ruleArray.length - 1].replacement;
}

function buildSegments(str, angleDeg, stepLen) {
  let segments = [];
  let stack = [];
  let x = 0, y = 0;
  let heading = -Math.PI / 2;
  let rad = (angleDeg * Math.PI) / 180;
  let depth = 0;
  for (let i = 0; i < str.length; i++) {
    let c = str[i];
    if (c === "F") {
      let nx = x + stepLen * Math.cos(heading);
      let ny = y + stepLen * Math.sin(heading);
      segments.push({ x1: x, y1: y, x2: nx, y2: ny, depth: depth });
      x = nx; y = ny;
    } else if (c === "+") { heading += rad;
    } else if (c === "-") { heading -= rad;
    } else if (c === "[") { stack.push({ x, y, heading }); depth++;
    } else if (c === "]") {
      let st = stack.pop();
      if (st) { x = st.x; y = st.y; heading = st.heading; }
      depth--;
    }
  }
  return segments;
}

function generateField(count) {
  blades = [];
  allGrown = false;
  for (let i = 0; i < count; i++) {
    let xPos = random(20, CANVAS_WIDTH - 20);
    let yOffset = random(0, 30);
    let stepLen = random(3, 7);
    let hue = random(80, 140);
    let sat = random(50, 90);
    let bri = random(40, 80);
    let startDelay = floor(random(0, 60));
    let lString = expandLSystem("F", grassRules, L_ITERATIONS);
    let segments = buildSegments(lString, L_ANGLE, stepLen);
    blades.push({ x: xPos, y: CANVAS_HEIGHT - 40 - yOffset, segments: segments, segIndex: 0, hue: hue, sat: sat, bri: bri, stepLen: stepLen, startDelay: startDelay });
  }
  blades.sort((a, b) => a.y - b.y);
}

function drawBladeSegments(blade, count) {
  let maxDepth = 3;
  for (let i = 0; i < count; i++) {
    let seg = blade.segments[i];
    let thickness = map(seg.depth, 0, maxDepth, 2.5, 0.5);
    let briShift = map(i, 0, blade.segments.length, 0, 15);
    strokeWeight(thickness);
    stroke(blade.hue, blade.sat, blade.bri + briShift, 90);
    line(blade.x + seg.x1, blade.y + seg.y1, blade.x + seg.x2, blade.y + seg.y2);
  }
}

function drawSky() {
  noStroke();
  let topColor = color(200, 40, 90);
  let bottomColor = color(50, 20, 95);
  for (let y = 0; y < CANVAS_HEIGHT - 40; y++) {
    let inter = map(y, 0, CANVAS_HEIGHT - 40, 0, 1);
    let c = lerpColor(topColor, bottomColor, inter);
    stroke(c);
    line(0, y, CANVAS_WIDTH, y);
  }
}

function drawGround() {
  noStroke();
  fill(90, 60, 25);
  rect(0, CANVAS_HEIGHT - 40, CANVAS_WIDTH, 40);
}

function regenerate() {
  if (regenButton) { regenButton.remove(); regenButton = null; }
  generateField(BLADE_COUNT);
  loop();
}

Your grass field now grows to life with staggered, organic timing. Every click of Regenerate produces an entirely unique meadow thanks to the stochastic L-system rules. Next up, you'll take on a secret mission to make the field sway in the wind.

Secret mission

Add Wind Simulation

Your grass field grows beautifully, but it feels frozen once it finishes. Real grass never stops moving. In this challenge, you'll add a sine-wave wind simulation that makes your blades sway continuously, with taller blades moving more than shorter ones and an interactive slider to control the breeze intensity.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally in your browser, so there are no ongoing costs. Decide whether to keep your files for further experimentation or delete them entirely.

Resources you used:

  • Local file: index.html
  • Local file: sketch.js

Keep everything running

No action needed. Choose this if you want to keep experimenting with your L-system grass field.

  • Your files stay on your Desktop, ready to open in the browser anytime.
  • Try tweaking the constants in sketch.js to generate different grass styles.

Pause - I'll come back to this later

There is nothing to pause. Your project is just two static files that use no background processes or ongoing resources.

  • Close the browser tab showing your grass field.
  • Close your text editor.
  • Your files remain on disk until you are ready to return.

Delete - I don't want to use this again

Remove your project files entirely. You can always rebuild the project from scratch.

  • Close the browser tab and your text editor.
  • Delete the project folder that contains index.html and sketch.js from your Desktop.

On macOS or Linux, open your terminal and run:

cd ~/Desktop
ls
  • Find your project folder name in the output, then run:
rm -rf [[PROJECT_FOLDER="your project folder name"]]

On Windows, open PowerShell and run:

cd ~\Desktop
ls
  • Find your project folder name in the output, then run:
Remove-Item -Recurse -Force [[PROJECT_FOLDER="your project folder name"]]

That's a Wrap!

That's a Wrap!

Nice work! You just built a procedural grass field from scratch using L-systems, turtle graphics, and p5.js.

You've learned how to:

  • Build an L-system engine that expands axioms using stochastic production rules to generate unique structures every time.
  • Render procedurally generated grass blades using turtle graphics interpretation in p5.js, with tapering thickness and natural color variation across 40+ blades.
  • Animate growth with staggered sprouting and a Regenerate button for infinite unique fields.
  • Secret Mission: Added wind simulation with sine-wave sway that propagates across the field, where taller blades sway more and an interactive slider controls wind intensity.

Ready to quiz yourself?