Learn Creative Coding (#55) - L-Systems: Realistic Botanical Structures
Learn Creative Coding (#55) - L-Systems: Realistic Botanical Structures

Last episode we built L-systems from scratch. String rewriting, turtle graphics, push/pop branching, fractal curves, stochastic rules. All the fundamentals. The trees we drew looked... tree-shaped. Branching structures that read as botanical. But they were wireframe sketches -- every branch the same thickness, every segment the same length, no leaves, no weight, no life. A skeleton without flesh.
Today we put flesh on those bones. Parametric L-systems where symbols carry data (length, width, age). Tapering that makes trunks thick and twigs thin. Tropism that bends branches under gravity. Leaves at terminal nodes. Flowers with probability. Wind animation. Multiple species from different grammars. By the end of this episode your L-system trees will look like actual plants, not abstract diagrams.
The core mechanism is identical to episode 54 -- iterate a string with replacement rules, interpret it with a turtle. What changes is HOW we interpret the string and what extra information we attach to each symbol.
Parametric L-systems: symbols with data
In episode 54, F just meant "move forward some fixed distance." But real branches aren't all the same length. A trunk segment is long. A twig is short. And the width matters -- trunks are thick, branches thinner, twigs barely visible. We need F to carry parameters.
The idea: instead of bare characters, each symbol can carry numeric values in parentheses. F(100, 8) means "move forward 100 pixels with a line width of 8." Rules can reference and transform these values:
// parametric L-system: symbols carry (length, width)
// rule: F(l, w) -> F(l*0.7, w*0.8) [+F(l*0.5, w*0.6)] [-F(l*0.5, w*0.6)]
function parseSymbol(token) {
const match = token.match(/^([A-Za-z])\(([^)]*)\)$/);
if (match) {
const params = match[2].split(',').map(Number);
return { char: match[1], params: params };
}
return { char: token, params: [] };
}
function tokenize(str) {
// split parametric string into tokens
const tokens = [];
let i = 0;
while (i < str.length) {
if (str[i].match(/[A-Za-z]/) && str[i + 1] === '(') {
// find closing paren
const start = i;
i += 2;
while (i < str.length && str[i] !== ')') i++;
tokens.push(str.slice(start, i + 1));
i++;
} else {
tokens.push(str[i]);
i++;
}
}
return tokens;
}
function iterateParametric(str, rules) {
const tokens = tokenize(str);
let result = '';
for (const token of tokens) {
const sym = parseSymbol(token);
if (sym.char === 'F' && sym.params.length === 2) {
const l = sym.params[0];
const w = sym.params[1];
// trunk grows slightly, then branches
result += 'F(' + (l * 0.7).toFixed(1) + ',' + (w * 0.85).toFixed(2) + ')';
result += '[+F(' + (l * 0.5).toFixed(1) + ',' + (w * 0.6).toFixed(2) + ')]';
result += '[-F(' + (l * 0.5).toFixed(1) + ',' + (w * 0.6).toFixed(2) + ')]';
} else {
result += token;
}
}
return result;
}
// start with a thick trunk
let tree = 'F(80,10)';
for (let gen = 0; gen < 4; gen++) {
tree = iterateParametric(tree, null);
}
Each generation, the trunk segment becomes 70% as long and 85% as wide. Each branch is 50% the parent's length and 60% the parent's width. After 4 iterations the outermost twigs are 80 * 0.5^4 = 5 pixels long and 10 * 0.6^4 = 1.3 pixels wide. Natural tapering from pure arithmetic.
The token parser handles the F(80,10) syntax -- split on parentheses, extract the numbers. Everything that isn't a parametric symbol (the +, -, [, ] characters) passes through unchanged. Same turtle drawing engine from episode 54, just reading the parameters instead of using fixed values.
Drawing parametric trees
The turtle interpreter needs to read those parameters:
function drawParametricTree(ctx, str, startX, startY, baseAngle, turnAngle) {
let x = startX;
let y = startY;
let dir = baseAngle;
const stack = [];
const tokens = tokenize(str);
for (const token of tokens) {
const sym = parseSymbol(token);
if (sym.char === 'F' && sym.params.length >= 2) {
const len = sym.params[0];
const width = sym.params[1];
ctx.strokeStyle = getTreeColor(width);
ctx.lineWidth = Math.max(0.5, width);
ctx.beginPath();
ctx.moveTo(x, y);
x += Math.cos(dir) * len;
y += Math.sin(dir) * len;
ctx.lineTo(x, y);
ctx.stroke();
} else if (sym.char === '+') {
dir -= turnAngle;
} else if (sym.char === '-') {
dir += turnAngle;
} else if (sym.char === '[') {
stack.push({ x, y, dir });
} else if (sym.char === ']') {
const state = stack.pop();
x = state.x;
y = state.y;
dir = state.dir;
}
}
}
function getTreeColor(width) {
// thick = dark brown trunk, thin = lighter brown branches
const t = Math.min(1, width / 10);
const r = Math.floor(60 + (1 - t) * 40);
const g = Math.floor(35 + (1 - t) * 25);
const b = Math.floor(10 + (1 - t) * 15);
return 'rgb(' + r + ',' + g + ',' + b + ')';
}
drawParametricTree(ctx, tree, 400, 580, -Math.PI / 2, 25 * Math.PI / 180);
Now the trunk is thick and dark brown, primary branches are medium and lighter, twigs are thin and pale. The color shifts with width -- a simple linear interpolation from dark (thick) to light (thin). Compared to the wireframe trees from episode 54, this already reads as significantly more natural. The visual hierarchy of thick-to-thin is how our brain identifies "tree" vs "abstract fractal."
The Math.max(0.5, width) is important -- browsers render sub-pixel widths inconsistently, and at 0.5px you still get a visible hairline. Below that it vanishes or creates weird antialiasing artifacts.
Tropism: gravity pulls branches down
Real branches don't grow in perfectly straight lines at fixed angles. Gravity acts on them. Heavy branches droop. The effect is called gravitropism (or just tropism in L-system literature). We model it by rotating the turtle slightly toward the gravity vector after every forward step:
function drawWithTropism(ctx, str, startX, startY, baseAngle, turnAngle, tropismStrength) {
let x = startX;
let y = startY;
let dir = baseAngle;
const stack = [];
const gravityAngle = Math.PI / 2; // straight down
const tokens = tokenize(str);
for (const token of tokens) {
const sym = parseSymbol(token);
if (sym.char === 'F' && sym.params.length >= 2) {
const len = sym.params[0];
const width = sym.params[1];
// apply tropism: rotate toward gravity
const angleDiff = gravityAngle - dir;
// normalize to [-PI, PI]
const normalized = Math.atan2(Math.sin(angleDiff), Math.cos(angleDiff));
dir += normalized * tropismStrength * (1 - width / 12);
ctx.strokeStyle = getTreeColor(width);
ctx.lineWidth = Math.max(0.5, width);
ctx.beginPath();
ctx.moveTo(x, y);
x += Math.cos(dir) * len;
y += Math.sin(dir) * len;
ctx.lineTo(x, y);
ctx.stroke();
} else if (sym.char === '+') {
dir -= turnAngle;
} else if (sym.char === '-') {
dir += turnAngle;
} else if (sym.char === '[') {
stack.push({ x, y, dir });
} else if (sym.char === ']') {
const state = stack.pop();
x = state.x;
y = state.y;
dir = state.dir;
}
}
}
The tropism calculation: find the angle difference between current heading and "straight down" (PI/2). Rotate toward it by tropismStrength scaled by how thin the branch is. Thin branches (low width) bend more because they have less structural rigidity. The trunk barely bends. Outer twigs droop noticeably.
A tropismStrength of 0.02-0.04 gives a realistic hardwood tree. Push it to 0.08-0.12 and you get a weeping willow effect -- all branches curving dramatically downward. Set it to 0 and you're back to the rigid geometric trees from before. The single parameter transforms the tree's entire character.
The (1 - width / 12) factor means the trunk (width ~10) barely bends while terminal twigs (width ~1) bend significantly. This matches physics -- beam deflection is inversely proportional to the cross-sectional moment of inertia. Thick beams resist bending, thin ones flex.
Adding leaves
Terminal branches -- the ones that don't branch further -- should have leaves. We can detect terminals: they're the F segments at the deepest level, typically the thinnest ones. If the width is below a threshold, draw a leaf instead of (or in addition to) the line:
function drawLeaf(ctx, x, y, dir, size) {
// simple pointed oval leaf
ctx.save();
ctx.translate(x, y);
ctx.rotate(dir);
ctx.fillStyle = 'rgba(' +
Math.floor(30 + Math.random() * 40) + ',' +
Math.floor(120 + Math.random() * 60) + ',' +
Math.floor(20 + Math.random() * 30) + ',0.8)';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(size * 0.4, -size * 0.5, size, 0);
ctx.quadraticCurveTo(size * 0.4, size * 0.5, 0, 0);
ctx.fill();
ctx.restore();
}
// in the drawing loop, after drawing a thin F segment:
if (sym.char === 'F' && sym.params[1] < 2.0) {
// terminal branch -- draw a leaf at the tip
drawLeaf(ctx, x, y, dir, 6 + Math.random() * 4);
}
The leaf shape is two quadratic Bezier curves forming a pointed oval -- quick to draw, reads clearly as a leaf at small sizes. The color has slight random variation in all channels so leaves aren't uniform green. Some are darker, some lighter, some slightly more yellow. This variation is subtle but makes the canopy feel alive rather than flat-filled.
The threshold (width < 2.0) determines how leafy the tree is. Lower threshold = fewer leaves (only on the thinnest twigs). Higher threshold = leaves everywhere (bushier). For a deciduous tree in summer, 2.0-3.0 works well. For a sparse winter tree with just a few remaining leaves, use 1.0.
Flowers and fruit
Same principle as leaves but with probability and different shapes:
function drawFlower(ctx, x, y, size) {
ctx.save();
ctx.translate(x, y);
// 5 petals around center
const petalColor = 'rgba(220,' + Math.floor(100 + Math.random() * 80) + ',180,0.9)';
ctx.fillStyle = petalColor;
for (let i = 0; i < 5; i++) {
const angle = (i / 5) * Math.PI * 2;
const px = Math.cos(angle) * size * 0.6;
const py = Math.sin(angle) * size * 0.6;
ctx.beginPath();
ctx.ellipse(px, py, size * 0.4, size * 0.25, angle, 0, Math.PI * 2);
ctx.fill();
}
// center
ctx.fillStyle = '#ffdd44';
ctx.beginPath();
ctx.arc(0, 0, size * 0.25, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// in terminal branch drawing:
if (sym.params[1] < 2.0) {
const roll = Math.random();
if (roll < 0.15) {
// 15% chance: flower
drawFlower(ctx, x, y, 5 + Math.random() * 3);
} else {
// 85% chance: leaf
drawLeaf(ctx, x, y, dir, 6 + Math.random() * 4);
}
}
Fifteen percent flowers, eighty-five percent leaves. The ratio gives you a tree in light bloom -- mostly green canopy with scattered pink spots. Change the ratio for different seasons: 0% flowers for summer/winter, 30-40% for peak spring bloom. You could even make the probability depend on position (flowers only on south-facing branches that get more sun) but that's getting into quite specific botanical simulation territory.
Wind animation
Wind bends branches. We can animate the tropism vector over time to simulate swaying:
let time = 0;
function animate() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// wind as a time-varying horizontal force
const windStrength = Math.sin(time * 0.8) * 0.03 + Math.sin(time * 2.1) * 0.01;
drawWithWind(ctx, tree, 400, 580, -Math.PI / 2, 25 * Math.PI / 180, windStrength);
time += 0.016;
requestAnimationFrame(animate);
}
function drawWithWind(ctx, str, startX, startY, baseAngle, turnAngle, wind) {
let x = startX;
let y = startY;
let dir = baseAngle;
let depth = 0;
const stack = [];
const tokens = tokenize(str);
for (const token of tokens) {
const sym = parseSymbol(token);
if (sym.char === 'F' && sym.params.length >= 2) {
const len = sym.params[0];
const width = sym.params[1];
// wind effect increases with depth (twigs move more than trunk)
const windEffect = wind * (depth * 0.4);
dir += windEffect;
ctx.strokeStyle = getTreeColor(width);
ctx.lineWidth = Math.max(0.5, width);
ctx.beginPath();
ctx.moveTo(x, y);
x += Math.cos(dir) * len;
y += Math.sin(dir) * len;
ctx.lineTo(x, y);
ctx.stroke();
} else if (sym.char === '+') {
dir -= turnAngle;
} else if (sym.char === '-') {
dir += turnAngle;
} else if (sym.char === '[') {
stack.push({ x, y, dir, depth });
depth++;
} else if (sym.char === ']') {
const state = stack.pop();
x = state.x;
y = state.y;
dir = state.dir;
depth = state.depth;
}
}
}
animate();
Two overlapping sine waves create organic wind motion -- the slow one (time * 0.8) is the main sway, the fast one (time * 2.1) adds gusty turbulence. The depth * 0.4 multiplier means the trunk barely moves but outer branches swing wide. This matches reality -- a tree trunk stays planted while leaves and twigs flutter. The frequencies being irrational multiples of each other prevents the motion from ever repeating exactly, which would look mechanical.
You don't need to regenerate the L-system string every frame. The string (the grammar output) is static -- it's the same tree structure. What changes frame-to-frame is how we INTERPRET that string during drawing. The wind only affects the turtle's heading during rendering. This keeps animation cheap -- no string processing per frame, just modified turtle movement.
Multiple species
Different grammars produce different tree morphologies. The branching angle, the shrink ratios, the number of branches per node -- these define a "species":
const species = {
oak: {
axiom: 'F(60,12)',
turnAngle: 30,
trunkShrink: 0.72,
branchShrink: 0.55,
widthDecay: 0.8,
tropism: 0.025,
iterations: 5
},
pine: {
axiom: 'F(80,10)',
turnAngle: 15,
trunkShrink: 0.85,
branchShrink: 0.4,
widthDecay: 0.7,
tropism: 0.01,
iterations: 6
},
willow: {
axiom: 'F(50,9)',
turnAngle: 35,
trunkShrink: 0.68,
branchShrink: 0.6,
widthDecay: 0.75,
tropism: 0.09,
iterations: 5
},
bush: {
axiom: 'F(30,6)',
turnAngle: 40,
trunkShrink: 0.6,
branchShrink: 0.65,
widthDecay: 0.7,
tropism: 0.015,
iterations: 4
}
};
function growSpecies(name) {
const sp = species[name];
let str = sp.axiom;
for (let i = 0; i < sp.iterations; i++) {
str = iterateWithParams(str, sp);
}
return str;
}
function iterateWithParams(str, sp) {
const tokens = tokenize(str);
let result = '';
for (const token of tokens) {
const sym = parseSymbol(token);
if (sym.char === 'F' && sym.params.length === 2) {
const l = sym.params[0];
const w = sym.params[1];
// trunk continues
result += 'F(' + (l * sp.trunkShrink).toFixed(1) + ',' + (w * sp.widthDecay).toFixed(2) + ')';
// right branch
result += '[+F(' + (l * sp.branchShrink).toFixed(1) + ',' + (w * sp.widthDecay * 0.8).toFixed(2) + ')]';
// left branch
result += '[-F(' + (l * sp.branchShrink).toFixed(1) + ',' + (w * sp.widthDecay * 0.8).toFixed(2) + ')]';
} else {
result += token;
}
}
return result;
}
The oak has wide angles and moderate shrinking -- spread out canopy. The pine has narrow angles and aggressive branch shrinking but slow trunk shrinking -- tall, thin, conical. The willow has wide angles AND high tropism -- branches arch out then droop down. The bush has aggressive trunk shrinking and many iterations -- stays short and dense.
Each species definition is just 6 numbers. The grammar rule structure is the same for all of them -- what changes is the parameters the rule uses. The grammar IS the genetics. Different parameter genes, same growth process, different phenotype. Exactly like real biology where the same developmental pathways produce wildly different organisms depending on gene expression levels.
A forest from seeds
Combine stochastic L-systems (from ep054) with species parameters and you can grow an entire forest:
function growForest(ctx, treeCount, canvasW, canvasH) {
const trees = [];
for (let i = 0; i < treeCount; i++) {
// random species
const speciesNames = Object.keys(species);
const sp = speciesNames[Math.floor(Math.random() * speciesNames.length)];
// random position along ground
const x = 50 + Math.random() * (canvasW - 100);
const y = canvasH - 20 - Math.random() * 30; // slight depth variation
// size variation
const scale = 0.6 + Math.random() * 0.8;
trees.push({ species: sp, x, y, scale, depth: y });
}
// sort by y position (back to front) for overlap
trees.sort((a, b) => a.depth - b.depth);
for (const t of trees) {
ctx.save();
ctx.translate(t.x, t.y);
ctx.scale(t.scale, t.scale);
const str = growSpecies(t.species);
const sp = species[t.species];
drawWithTropism(ctx, str, 0, 0, -Math.PI / 2, sp.turnAngle * Math.PI / 180, sp.tropism);
ctx.restore();
}
}
growForest(ctx, 12, 800, 600);
Sort trees by Y position and draw back-to-front. Trees further back (lower Y, closer to the horizon) get drawn first, so foreground trees overlap them naturally. The scale variation (0.6 to 1.4) creates depth -- smaller trees read as further away. The slight Y randomness adds ground unevenness.
Twelve trees is enough to fill a canvas without it looking sparse. Each is a random species with random scale, so the forest has variety. Some tall pines in the back, a willow on one side, bushes in the foreground, oaks scattered throughout. All from the same codebase -- the grammar parameters do the differentiation.
Growth animation
Instead of drawing the full tree at once, animate the generation process. Show generation 1, then 2, then 3, interpolating branch extension:
let currentGen = 0;
let targetGen = 5;
let genProgress = 0; // 0 to 1 within current generation
let strings = [];
// pre-generate all generations
let str = species.oak.axiom;
strings.push(str);
for (let i = 0; i < targetGen; i++) {
str = iterateWithParams(str, species.oak);
strings.push(str);
}
function animateGrowth() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// current generation string
const current = strings[currentGen];
// draw with length scaled by genProgress for newest segments
drawGrowing(ctx, current, 400, 580, -Math.PI / 2,
species.oak.turnAngle * Math.PI / 180, genProgress);
genProgress += 0.01;
if (genProgress >= 1) {
genProgress = 0;
currentGen++;
if (currentGen >= targetGen) {
currentGen = targetGen;
genProgress = 1;
return; // fully grown
}
}
requestAnimationFrame(animateGrowth);
}
function drawGrowing(ctx, str, startX, startY, baseAngle, turnAngle, progress) {
// similar to drawParametricTree but newest-generation segments
// get their length multiplied by progress (0->1)
// older segments are always full length
// ... (same turtle logic, with length *= progress for terminal branches)
}
animateGrowth();
The trick: pre-generate all generation strings. During animation, draw the current generation but scale the newest branches by genProgress. When progress hits 1.0, advance to the next generation. The effect is branches growing outward in real time -- you watch the tree extend from seed to full canopy. Each generation adds a burst of new growth at the tips.
This is genuinly satisfying to watch :-) The tree starts as a single trunk segment, then branches appear and extend, then those branches grow sub-branches, and so on. It looks like a time-lapse of a real plant growing. The structural self-similarity means every growth burst looks like a smaller version of the previous one, which is exactly how real trees grow -- apical meristems at every branch tip producing the same growth pattern at every scale.
Seasonal color
One more flourish -- cycle the leaf and flower colors by a "season" parameter:
function getSeasonalColors(season) {
// season: 0=spring, 0.25=summer, 0.5=autumn, 0.75=winter
if (season < 0.2) {
// spring: light green, some flowers
return { leaf: [140, 200, 80], flowerProb: 0.25, leafProb: 0.7 };
} else if (season < 0.45) {
// summer: dark green, full canopy
return { leaf: [40, 140, 30], flowerProb: 0.02, leafProb: 0.95 };
} else if (season < 0.7) {
// autumn: red/orange/yellow mix
return { leaf: [200, 100, 30], flowerProb: 0, leafProb: 0.6 };
} else {
// winter: bare, very few brown leaves
return { leaf: [120, 90, 50], flowerProb: 0, leafProb: 0.05 };
}
}
// animate seasons slowly
let seasonTime = 0;
function seasonLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const colors = getSeasonalColors(seasonTime % 1);
drawTreeWithSeason(ctx, tree, 400, 580, colors);
seasonTime += 0.001; // very slow cycle
requestAnimationFrame(seasonLoop);
}
Spring: light green canopy, flowers blooming. Summer: dense dark canopy, full coverage. Autumn: oranges and reds, leaves dropping (lower probability). Winter: bare branches, almost no leaves. Cycle it slowly and you get a year passing in about 16 seconds. The structural tree stays the same -- only the decoration (leaves, colors, coverage) changes with season.
You could combine this with the wind animation. Winter with strong wind. Summer with light breeze. Autumn with falling leaf particles (spawn particles at terminal nodes with downward + sideways velocity when leafProb decreases). The L-system provides the structure; the rendering provides the life.
The jump from 2D to what's next
Everything today is 2D. The tree is flat -- a silhouette. Real trees are 3D objects with branches going in all directions. You could extend the turtle to operate in 3D (add yaw/pitch/roll instead of just one rotation angle, push/pop 3D state), but that requires a 3D rendering pipeline. We'll get there later in the series when we hit 3D graphics.
But the 2D parametric trees we built today are already compelling for generative art, game backgrounds, illustration, and procedural scenery. A seeded forest with seasonal animation and wind makes a living desktop wallpaper. A grove of different species with tropism and leaves makes a nature illustration. The grammar stays simple -- the visual richness comes from how we interpet and decorate the output.
The emergent systems arc continues. We've done grid automata (ep047-049), free agents (ep050-051), continuous chemistry (ep052-053), and now formal grammars (ep054-055). All produce complex output from simple rules. Next up we're going to try something different -- autonomous agents that crawl across a surface, leaving trails behind them, creating paths and patterns through nothing but local movement decisions. No grid. No string. Just walkers and their marks.
't Komt erop neer...
- Parametric L-systems attach numeric parameters to symbols:
F(length, width)instead of bareF. Rules transform these parameters each generation -- length shrinks, width tapers. The turtle reads the parameters during drawing, producing thick trunks that thin out to delicate twigs - Tropism simulates gravity by rotating the turtle toward "down" after each step. The rotation scales with branch thinness (thin branches bend more). A single float parameter transforms a geometric tree into a weeping willow or a sturdy oak
- Leaves at terminal branches (below a width threshold) use simple Bezier oval shapes with randomized green tones. Flowers get placed with probability -- 15% flowers, 85% leaves gives a tree in light spring bloom
- Wind animation modifies the turtle heading by a time-varying offset scaled by depth. The string never changes -- only the interpretation during rendering. Outer branches sway more than the trunk, matching real physics
- Species are defined by 6 parameters (angles, shrink factors, tropism). Same grammar structure, different numbers, wildly different trees. Oak vs pine vs willow vs bush -- the parameters ARE the genetics
- Forest generation: grow multiple stochastic trees at random positions sorted back-to-front for depth overlap. A dozen trees with mixed species fills a convincing scene
- Growth animation pre-generates all generation strings and interpolates branch extension over time. Watch the tree grow from seed to full canopy as each iteration adds a burst of new branching at the tips
Sallukes! Thanks for reading.
X
Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)
Your next target is to reach 90 posts.
You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word
STOPCheck out our last posts: