L-Systems
Rewriting rules that grow plants from simplicity
An L-system starts with a seed — a simple string. Each iteration, rules rewrite symbols into new strings. With turtle graphics (forward, turn, push, pop), these strings become branches, curves, and intricate structures. Click preset plants or tune the angles and depths to grow your own.
Axiom: XRules: X → F+[[X]-X]-F[-FX]+X, F → FF
String Rewriting
An L-system starts as a simple string called the axiom. Each iteration, every character transforms according to its rule — X becomes F+[[X]-X]-F[-FX]+X, and the result grows exponentially. After four iterations, a single symbol becomes thousands.
function applyRules(str, iterations) {
let result = str;
for (let i = 0; i < iterations; i++) {
let newResult = '';
for (const char of result) {
newResult += rules[char] || char;
}
result = newResult;
}
return result;
}
The beauty is in what remains unchanged: characters without rules pass through untouched. F draws forward, + turns left, - turns right. All the growth comes from X and Y — symbols that only expand, never draw.
The non-obvious trick: separating expansion symbols (X, Y) from drawing symbols (F, +, -) means the structure can grow more complex at each iteration without changing what any single F draws. A five-iteration fern has the same F command at every scale — only the context differs.
Turtle Graphics
The string becomes a drawing through turtle graphics — a virtual cursor that moves and turns as it reads each command. The turtle starts at the bottom center, pointing upward. Every F moves it forward, drawing a line. Every + turns left, - turns right.
for (const char of str) {
if (char === 'F') {
const newX = x + Math.cos(a) * len;
const newY = y + Math.sin(a) * len;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(newX, newY);
ctx.stroke();
x = newX; y = newY;
} else if (char === '+') {
a -= aRad;
} else if (char === '-') {
a += aRad;
}
}
The angle matters enormously. Dragon curves use 90° turns — every bend is a sharp corner. Ferns use 25° turns — gentle angles that let branches curve organically. Experiment with the angle slider to see how the same rewrite rules produce radically different shapes.
Branch Stacks
Real plants branch. L-systems achieve this with [ and ] — push and pop operations that save and restore the turtle's state. When the turtle encounters [, it remembers its position and direction. When it sees ], it returns to that saved state and continues from there.
else if (char === '[') {
stack.push({ x, y, a, depth });
depth++;
} else if (char === ']') {
const state = stack.pop();
x = state.x;
y = state.y;
a = state.a;
depth = state.depth;
}
This is why the fern branches: the rule F+[[X]-X]-F[-FX]+X contains multiple nested brackets. Each [ starts a branch. The turtle draws one branch, then pops back to the fork point and draws the next. The stack depth tracks how deep we are in the hierarchy.
Without brackets, an L-system would be a single unbroken path. With them, it creates arbitrary tree structures — every branch spawns sub-branches, which spawn sub-sub-branches, all from a linear string of symbols.
Depth Coloring
The fern doesn't show uniform lines. The main trunk is dark forest teal. The tips are lighter, more yellow-green. This gradient isn't random — it encodes the recursion depth of each segment.
const depthRatio = Math.min(depth / 8, 1);
const r = Math.round(45 + depthRatio * 80);
const g = Math.round(106 - depthRatio * 40);
const b = Math.round(93 - depthRatio * 30);
ctx.strokeStyle = `rgb(${r}, ${g}, ${b})`;
Every [ increments depth. Every ] restores it. The main trunk is depth 0 — deep teal. Each nested branch gets lighter as the ratio approaches 1. By the time we reach the frond tips, the color has shifted toward a yellow-green that suggests new growth.
This is computationally cheap but visually significant: the depth variable we already track for stack management now doubles as a pigment signal, no extra state required.
Bounds Calculation
Before drawing a single line, the code runs through the entire string twice. First to calculate bounds — where does the shape end? How wide and tall is it? Then it scales and centers the result to fit the canvas. Without this pre-pass, the fern might draw partially off-screen or be too small to see.
for (const char of str) {
if (char === 'F') {
const newX = testX + Math.cos(testA) * len;
const newY = testY + Math.sin(testA) * len;
minX = Math.min(minX, newX);
maxX = Math.max(maxX, newX);
// ...
}
}
const scale = Math.min(
(canvas.width * 0.85) / width,
(canvas.height * 0.85) / height
);
The scale factor is the minimum of horizontal and vertical fit ratios, ensuring the shape fits in both dimensions. The 85% multiplier leaves a small margin around the edges. This pre-pass means every preset — from the compact Hilbert to the sprawling fern — renders centered and visible without manual tuning.
What This Reveals
L-systems are a model of developmental growth. A seed contains no image of the adult plant. It contains rules — simple, local instructions that unfold through iteration. The complexity emerges from the recursion, not from the rules themselves. A six-character axiom and two rewrite rules generate a fern with thousands of segments, each perfectly positioned relative to its neighbors.
This is the opposite of procedurally specifying every branch angle and length. The programmer doesn't draw the fern. They draw the rules that draw the fern. The difference matters: change a single character in the rule, and the entire shape transforms. The system is fragile to small edits but powerful for generating organic variety from compressed specifications.