Learn Creative Coding (#25) - Composition Algorithms: Rule-Based Design

in StemSocial2 days ago

Learn Creative Coding (#25) - Composition Algorithms: Rule-Based Design

cc-banner

Last episode we built the seed toolkit -- mulberry32 PRNG, utility functions for picking, shuffling, weighted choices, gaussian distributions, and seeded noise. We can now reproduce any generative piece from a single number. But here's the thing: a seed-based system is only as interesting as the compositions it produces. And so far, our compositions have been mostly "scatter things on screen" or "draw things in a grid." Those work. But if you look at professional generative art -- Fidenza, Ringers, Archetype -- the layouts feel intentionally designed. There's visual weight. Balance. Focal points. Rhythm. Not random, not rigid, but somewhere in between.

That's what composition algorithms give you. Code that arranges elements according to design principles. This episode bridges the gap between "generative" and "designed." We'll implement recursive subdivision (the technique behind Mondrian-style compositions), L-systems for organic branching, space-filling curves, and classical composition rules like the rule of thirds. We'll also talk about margins, negative space, and why the empty parts of your canvas matter as much as the filled parts.

Good composition is what separates "interesting experiment" from "I'd hang that on my wall." These techniques apply to every generative piece you'll ever make, regardless of what visual elements you're using. Think of this as the graphic design episode of the series :-)

Recursive subdivision

Take a rectangle. Split it. Take each half. Maybe split those too. Repeat until your rectangles are small enough or you decide to stop randomly. This is how Piet Mondrian composed (by hand, incredibly), and it's one of the most powerful generative composition strategies.

The idea is simple but the results are rich. Every split decision creates hierarchy -- big areas next to small areas, dense regions next to sparse ones. And because the splits are recursive, the structure is self-similar at different scales. Big rectangles contain smaller rectangles which contain even smaller ones. Fractal composition.

function subdivide(x, y, w, h, depth, R) {
  // stop condition: too small or random stop
  if (depth > 6 || w < 30 || h < 30 || (depth > 2 && R.random(0, 1) < 0.3)) {
    // draw this rectangle
    let palette = ['#264653', '#2a9d8f', '#e9c46a', '#e76f51', '#f4f1de'];
    fill(R.pick(palette));
    stroke(20);
    strokeWeight(3);
    rect(x, y, w, h);
    return;
  }

  // split horizontally or vertically based on aspect ratio
  if (w > h || (w === h && R.random(0, 1) > 0.5)) {
    // vertical split
    let split = w * R.random(0.3, 0.7);
    subdivide(x, y, split, h, depth + 1, R);
    subdivide(x + split, y, w - split, h, depth + 1, R);
  } else {
    // horizontal split
    let split = h * R.random(0.3, 0.7);
    subdivide(x, y, w, split, depth + 1, R);
    subdivide(x, y + split, w, h - split, depth + 1, R);
  }
}

function setup() {
  createCanvas(600, 600);
  let R = createArt(42);  // seeded toolkit from last episode
  background(20);
  subdivide(10, 10, 580, 580, 0, R);
}

There's a lot of design decisions packed into those parameters. The R.random(0.3, 0.7) for split position ensures splits aren't too extreme -- no ultra-thin slivers. If you allowed 0.05 to 0.95, you'd get tiny ribbons that look broken rather than designed. The depth check (depth > 6) prevents infinite recursion, while the random early stop (depth > 2 && R.random() < 0.3) creates variation in density. Some branches of the recursion go deep (lots of small rectangles), others stop early (fewer, larger rectangles). That unevenness is what makes it visually interesting.

The aspect ratio check (w > h) is subtle but important. It biases vertical splits for wide rectangles and horizontal splits for tall ones. Without it, you'd get lots of very thin, very tall rectangles (or very wide, very short ones) that look awkward. The aspect-aware splitting keeps the proportions roughly reasonable at every level.

Remember the createArt() function we built last episode? That R object carries the seeded PRNG through the entire recursion. Same seed, same subdivision, every time. Different seed, completely different layout. The composition is deterministic but parameterized.

The Mondrian variation

For a more Mondrian feel, leave most rectangles white and only color a few:

function subdivideMondrian(x, y, w, h, depth, R) {
  if (depth > 5 || w < 40 || h < 40) {
    stroke(20);
    strokeWeight(4);

    // 75% white, 25% colored
    if (R.random(0, 1) < 0.75) {
      fill(250);
    } else {
      fill(R.pick(['#d40920', '#1356a2', '#f7d842']));
    }

    rect(x, y, w, h);
    return;
  }

  // same split logic as before
  if (w > h || (w === h && R.random(0, 1) > 0.5)) {
    let split = w * R.random(0.3, 0.7);
    subdivideMondrian(x, y, split, h, depth + 1, R);
    subdivideMondrian(x + split, y, w - split, h, depth + 1, R);
  } else {
    let split = h * R.random(0.3, 0.7);
    subdivideMondrian(x, y, w, split, depth + 1, R);
    subdivideMondrian(x, y + split, w, h - split, depth + 1, R);
  }
}

Three colors. Mostly white. Thick black lines. Instantly recognizable. The algorithm captures the essence of a visual style. And because it's seeded, you can generate hundreds of Mondrian-style compositions and curate the best ones -- exactly the workflow we discussed in episode 23.

The weighted 75/25 split between white and color is doing a lot of work here. If you did 50/50, the composition would feel heavy and cluttered. If you did 90/10, it might feel too sparse. That ratio is a creative parameter -- play with it across different seeds and you'll quickly develop an intuition for what feels balanced.

L-systems: growing things from grammar

L-systems (Lindenmayer systems) generate branching structures through string rewriting. A botanist invented them to model plant growth, which tells you something about how organic the results look.

The concept: start with a string (the axiom). Apply replacement rules repeatedly. Then interpret the final string as drawing instructions. It's like a tiny language that describes how to draw.

function lSystem(axiom, rules, iterations) {
  let current = axiom;

  for (let i = 0; i < iterations; i++) {
    let next = '';
    for (let char of current) {
      next += rules[char] || char;
    }
    current = next;
  }

  return current;
}

// a simple tree
let rules = {
  'F': 'FF+[+F-F-F]-[-F+F+F]'
};

let result = lSystem('F', rules, 4);

The string grows exponentialy. After 1 iteration: 17 characters. After 4: thousands. Each iteration applies the rule to every F in the string, so the string roughly triples in length each time. That's the power of L-systems -- a tiny grammar produces massive complexity.

Now we interpret the string as drawing instructions:

  • F = move forward and draw
  • + = turn right
  • - = turn left
  • [ = save position (push to stack)
  • ] = restore position (pop from stack)

The bracket commands are the magic. When you hit [, you save your current position and direction. Then you draw a branch. When you hit ], you teleport back to the saved position and continue from there. This is how branching works -- the stack remembers every fork point.

function drawLSystem(str, startX, startY, length, angle) {
  let x = startX;
  let y = startY;
  let dir = -PI / 2;  // start pointing up
  let stack = [];

  stroke(100, 180, 100);
  strokeWeight(1);

  for (let char of str) {
    switch (char) {
      case 'F':
        let nx = x + cos(dir) * length;
        let ny = y + sin(dir) * length;
        line(x, y, nx, ny);
        x = nx;
        y = ny;
        break;
      case '+':
        dir += angle;
        break;
      case '-':
        dir -= angle;
        break;
      case '[':
        stack.push({ x, y, dir });
        break;
      case ']':
        let state = stack.pop();
        x = state.x;
        y = state.y;
        dir = state.dir;
        break;
    }
  }
}

function setup() {
  createCanvas(600, 600);
  background(15);

  let result = lSystem('F', { 'F': 'FF+[+F-F-F]-[-F+F+F]' }, 4);
  drawLSystem(result, 300, 580, 3, radians(22.5));
}

A tree grows from the bottom of the screen. The cos/sin here is the same polar coordinate math from episode 13 -- converting angle + distance into x,y displacement. The branching angle (22.5 degrees) dramatically changes the look:

  • 20 degrees = tight, coniferous
  • 30 degrees = broad, deciduous
  • 45 degrees = wild, windswept
  • 90 degrees = blocky, geometric

The step length (3 pixels here) controls the overall size. Shorter steps = smaller tree with finer detail. Longer steps = bigger tree but fewer visible branches before it flies off the canvas. You need to balance step length with iteration count -- more iterations means more branching detail, but you need shorter steps to keep it all visible.

Different grammars, different worlds

The grammar (axiom + rules + angle) defines the entire visual language. Change the rules and you get something completly different:

// Koch snowflake
let koch = {
  axiom: 'F--F--F',
  rules: { 'F': 'F+F--F+F' },
  angle: 60,
  iterations: 4
};

// Sierpinski triangle
let sierpinski = {
  axiom: 'F-G-G',
  rules: { 'F': 'F-G+F+G-F', 'G': 'GG' },
  angle: 120,
  iterations: 6
};

// Dragon curve
let dragon = {
  axiom: 'FX',
  rules: { 'X': 'X+YF+', 'Y': '-FX-Y' },
  angle: 90,
  iterations: 12
};

The Koch snowflake is a fractal coastline -- infinitely detailed edges. The dragon curve is a space-filling path that never crosses itself. The Sierpinski triangle is fractal recursion, triangles within triangles. Each grammar produces a completely different visual world from the same interpreter function. That's the beauty of L-systems -- you write the interpreter once, then experiment with grammars forever.

See how the Sierpinski grammar uses two symbols (F and G) where the tree only used one? G has its own rule (GG -- just doubles itself) but draws the same way as F. This asymmetry between F and G is what creates the triangular structure. Tiny grammar differences produce massive visual differences.

Space-filling curves

A space-filling curve visits every point in a 2D area using a single continuous path. The most famous is the Hilbert curve. It's mathematically beautiful -- a one-dimensional line that fills a two-dimensional space.

The full recursive algorithm is complex, so here's a cleaner version using index-to-coordinate conversion:

function d2xy(n, d) {
  let x = 0, y = 0;
  for (let s = 1; s < (1 << n); s *= 2) {
    let rx = (d / 2) & 1;
    let ry = ((d & 1) ^ rx) ? 0 : 1;
    if (ry === 0) {
      if (rx === 1) { x = s - 1 - x; y = s - 1 - y; }
      [x, y] = [y, x];
    }
    x += s * rx;
    y += s * ry;
    d = Math.floor(d / 4);
  }
  return { x, y };
}

function hilbertPoints(order) {
  let size = 1 << order;
  let points = [];
  for (let d = 0; d < size * size; d++) {
    points.push(d2xy(order, d));
  }
  return points;
}

The bitwise operations look intimidating. Don't worry about understanding every line -- the key insight is that d2xy converts a single integer (the position along the curve) into a 2D coordinate. The order parameter controls resolution: order 4 gives a 16x16 grid (256 points), order 6 gives 64x64 (4096 points). Higher order = smoother curve, more points.

Now draw it with color:

function setup() {
  createCanvas(600, 600);
  background(15);

  let points = hilbertPoints(6);  // 64x64 = 4096 points
  let scale = width / 64;

  noFill();
  strokeWeight(1.5);

  for (let i = 1; i < points.length; i++) {
    let ratio = i / points.length;
    let hue = ratio * 360;

    stroke(color('hsl(' + hue + ', 80%, 60%)'));
    line(
      points[i-1].x * scale + scale / 2,
      points[i-1].y * scale + scale / 2,
      points[i].x * scale + scale / 2,
      points[i].y * scale + scale / 2
    );
  }
}

A single line that fills the entire canvas, smoothly transitioning through the color spectrum. Because the Hilbert curve preserves spatial locality (points that are close along the curve are close in 2D space), the color transitions are smooth rather than chaotic. Nearby pixels have similar hues. That's a property you can exploit -- use Hilbert curves to map sequential data onto a 2D canvas while preserving relationships between adjacent data points.

Creative uses: map a photo's pixels along a Hilbert curve to create those trendy "unwound image" visualizations. Use the curve to guide particle movement. Lay out a grid of elements in Hilbert order so nearby elements are related. The curve gives you a principled way to fill space while maintaining spatial coherence.

Combining composition strategies

The real power comes from mixing techniques. Subdivision handles layout. Content fills each cell. L-systems, circles, lines, whatever you want -- each subdivided cell gets its own mini-artwork:

function subdivideWithContent(x, y, w, h, depth, R) {
  if (depth > 4 || w < 60 || h < 60) {
    // choose content type for this cell
    let content = R.weighted([
      { value: 'tree', weight: 3 },
      { value: 'circles', weight: 2 },
      { value: 'empty', weight: 2 },
      { value: 'lines', weight: 3 },
    ]);

    stroke(40);
    strokeWeight(2);
    noFill();
    rect(x, y, w, h);

    switch (content) {
      case 'tree':
        drawMiniTree(x + w/2, y + h, Math.min(w, h) * 0.4, R);
        break;
      case 'circles':
        drawConcentricCircles(x + w/2, y + h/2, Math.min(w, h) * 0.4, R);
        break;
      case 'lines':
        drawParallelLines(x, y, w, h, R);
        break;
      // 'empty' does nothing -- negative space!
    }
    return;
  }

  // subdivision logic continues...
  if (w > h || (w === h && R.random(0, 1) > 0.5)) {
    let split = w * R.random(0.3, 0.7);
    subdivideWithContent(x, y, split, h, depth + 1, R);
    subdivideWithContent(x + split, y, w - split, h, depth + 1, R);
  } else {
    let split = h * R.random(0.3, 0.7);
    subdivideWithContent(x, y, w, split, depth + 1, R);
    subdivideWithContent(x, y + split, w, h - split, depth + 1, R);
  }
}

See the 'empty' option with weight 2? That means roughly 20% of cells stay empty. This is intentional negative space -- which we'll talk more about in a minute. The overall composition is structured (subdivision handles the grid), but the details are varied (different content per cell, weighted by the seeded PRNG). The R.weighted function is the one we built last episode -- controllable probability distributions for content selection.

Each helper function (drawMiniTree, drawConcentricCircles, drawParallelLines) takes the cell bounds and the R object so everything stays seeded and deterministic. You could swap in any visual content -- mini flow fields, noise textures, geometric patterns, even tiny L-systems. The subdivision provides the frame, you fill it with whatever you want.

Classical composition rules

Even generative art benefits from rules that painters and photographers have used for centuries.

Rule of thirds: place focal points at the 1/3 and 2/3 positions, not dead center. There are four "power points" where the third-lines intersect. Our eyes naturally rest there.

Visual weight balance: dense areas need to be balanced by empty space. A cluster of elements in the top-left wants breathing room in the bottom-right.

Hierarchy: one dominant element, several secondary, many small tertiary. Not everything can be loud -- you need contrast in scale.

Rhythm: repetition with variation. Same shape, different sizes. Same color family, different values. Consistent enough to feel unified, varied enough to stay interesting.

We can code the rule of thirds directly:

function biasedPosition(R, w, h) {
  // the four power points
  let thirdPoints = [
    { x: w / 3, y: h / 3 },
    { x: 2 * w / 3, y: h / 3 },
    { x: w / 3, y: 2 * h / 3 },
    { x: 2 * w / 3, y: 2 * h / 3 },
  ];

  let target = R.pick(thirdPoints);
  // gaussian distribution clusters elements near the target
  return {
    x: R.gaussian(target.x, w * 0.1),
    y: R.gaussian(target.y, h * 0.1)
  };
}

The R.gaussian function (from last episode's toolkit) produces a normal distribution around the target. Most elements land close to the power point. A few drift outward. The standard deviation (w * 0.1) controls how tight the cluster is -- 10% of canvas width means most elements land within a 60-pixel radius on a 600px canvas. Tight enough to feel intentional, loose enough to feel natural.

Compare this to R.random(0, w) which distributes elements uniformly. Uniform distribution has no focal points, no emphasis, no hierarchy. It feels scattered. Gaussian distribution around rule-of-thirds points creates instant visual structure. Your eye has somewhere to go.

Margins and breathing room

The simplest composition improvement you can make: don't draw to the edge. Add margins.

This sounds boring but it's huge. Real designers obsess over margins because they frame the content, give the eye a resting place at the edges, and make the piece feel intentional rather than accidental overflow.

let margin = width * 0.08;  // 8% margin
let innerWidth = width - margin * 2;
let innerHeight = height - margin * 2;

// constrain ALL element placement to the inner area
for (let i = 0; i < 200; i++) {
  let x = margin + R.random(0, innerWidth);
  let y = margin + R.random(0, innerHeight);
  // draw elements...
}

8% margin is a solid starting point. For dense compositions, try 12-15%. For sparse compositions with lots of negative space, 5% might be enough. You can also use non-uniform margins: more space at the bottom (classical painting convention, the canvas feels grounded), more at the top (modern/minimal, the content feels like it's floating up), or asymmetric left-right for dynamic tension.

If you go back and look at professional generative art on fxhash or Art Blocks, almost all of it has margins. The canvas has a border of empty space, and the content lives inside that frame. Such a small thing, but it immediately makes the output feel more polished. Go add margins to any sketch you've built so far -- the galaxy from episode 15, the flow fields from episode 12 -- and see the difference. It's one of those "can't unsee it" moments.

Negative space: what you DON'T draw matters

One last composition concept that's critically important: negative space. The empty areas of your canvas are as important as the filled areas. If every pixel is packed with detail, there's nowhere for the eye to rest. If there's too much empty space, the piece feels unfinished. The balance between filled and empty is composition.

Professional designers obsess over whitespace. Generative artists should too. But "leave space empty" needs to be an explicit decision in your algorithm, not an accident.

function shouldDraw(x, y, R, w, h) {
  // create a zone of lower density in the center
  let cx = w / 2;
  let cy = h / 2;
  let distFromCenter = dist(x, y, cx, cy);
  let maxDist = dist(0, 0, cx, cy);
  let normalizedDist = distFromCenter / maxDist;

  // elements near the center have lower probability
  // creates a donut-shaped density pattern
  if (normalizedDist < 0.3) {
    return R.random(0, 1) < 0.15;  // 15% chance near center
  }
  return R.random(0, 1) < 0.8;    // 80% chance elsewhere
}

This creates a composition with a clear center void -- a river of empty space that gives the dense outer regions more visual impact. The contrast between filled and empty IS the composition. Without it, you have uniform noise. With it, you have structure.

Other approaches: exclusion zones that follow noise contours (organic-looking voids), density gradients that fade toward edges (vignette effect), or corridors of empty space cutting through dense fields. Each creates a different visual rhythm. The key insight is that negative space should be designed, not accidental.

Remember in the subdivideWithContent function above, we gave 'empty' a weight of 2? That's negative space by design. One in five cells deliberately left blank. Those empty cells give the filled cells room to breathe and prevent the composition from feeling claustrophobic.

Beyond the grid: warped and organic composition

Grids and subdivisions are a starting point, not a destination. Once you understand grid-based composition, you can break the grid intentionally for effect.

Warped grids: apply noise displacement to grid positions. The structure is still there but it feels organic instead of mechanical. Remember the Perlin noise from episode 12? Use it to push grid points around:

function setup() {
  createCanvas(600, 600);
  background(245, 240, 235);
  noFill();
  stroke(40);

  let R = createArt(42);
  let noiseField = createNoise(42);
  let spacing = 30;
  let cols = Math.floor(width / spacing);
  let rows = Math.floor(height / spacing);
  let warpStrength = 12;

  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < cols; col++) {
      let x = col * spacing + spacing / 2;
      let y = row * spacing + spacing / 2;

      // warp position with noise
      let nx = noiseField(x * 0.01, y * 0.01) * warpStrength;
      let ny = noiseField(x * 0.01 + 100, y * 0.01 + 100) * warpStrength;

      let size = 4 + noiseField(x * 0.02, y * 0.02) * 12;
      ellipse(x + nx, y + ny, size);
    }
  }
}

The grid is still there if you squint -- the elements are roughly evenly spaced. But the noise displacement gives it an organic wobble, like the grid is printed on fabric that's been gently pulled. The warpStrength parameter controls how much the grid deforms. Set it to 0 and you get a perfect grid. Set it to 50 and the grid is unrecognizable. Somewhere around 10-15 hits that sweet spot where you can feel the underlying structure but it doesn't look mechanical.

Golden ratio placement: for key focal elements, position them at golden ratio intersections (approximately 38% and 62% from each edge). This naturally creates pleasing proportions:

let phi = 0.618;
let focalPoints = [
  { x: width * phi, y: height * phi },
  { x: width * (1 - phi), y: height * phi },
  { x: width * phi, y: height * (1 - phi) },
  { x: width * (1 - phi), y: height * (1 - phi) },
];

Similar to rule of thirds but based on the golden ratio (1.618...) instead of even thirds. Some artists swear by the golden ratio, others think it's overrated. Try both and see which you prefer for your aesthetic. The math doesn't care -- it's all just weighted positioning :-)

Putting it all together

The best generative compositions often combine several of these techniques. Subdivision creates the overall layout. Margins frame everything. Rule-of-thirds biasing positions the focal elements. Negative space gives the eye room to rest. And all of it flows through the seed system from last episode.

Here's a conceptual sketch that combines multiple composition strategies:

function composedPiece(seed) {
  let R = createArt(seed);
  let noiseField = createNoise(seed + 1000);

  createCanvas(800, 800);
  background(250, 247, 240);

  let margin = width * 0.1;

  // subdivision provides the layout framework
  subdivideWithContent(
    margin, margin,
    width - margin * 2, height - margin * 2,
    0, R
  );

  // focal accent at a rule-of-thirds point
  let focal = biasedPosition(R, width, height);
  noStroke();
  fill(R.pick(['#e76f51', '#264653', '#2a9d8f']));
  ellipse(focal.x, focal.y, R.random(20, 50));
}

Layer by layer, the composition goes from random to intentional. Margins frame it. Subdivision structures it. Content fills it. Focal points anchor it. Negative space lets it breathe. Each technique addresses a different compositional concern. Together they produce output that looks designed -- not scattered, not algorithmic, but intentionally composed by an artist who happens to work in code.

And these aren't just techniques for generative art. Every website layout, every app interface, every magazine spread uses these same principles -- grids, hierarchy, whitespace, focal points. Learning composition through code gives you a deep understanding that transfers to any visual medium. Whether you end up doing generative art, web design, data visualization, or motion graphics, the spatial reasoning you build by implementing these algorithms will serve you everywhere.

't Komt erop neer...

  • Recursive subdivision creates dynamic, balanced layouts (Mondrian-style and beyond)
  • Split ratios, depth limits, and random early stops control the density and hierarchy
  • L-systems = string rewriting into branching structures (trees, fractals, organic forms)
  • The grammar (axiom + rules + angle) defines the entire visual language of an L-system
  • Space-filling curves (Hilbert) visit every point in a 2D area with a single continuous path
  • Combine composition strategies: subdivide for layout, fill cells with varied content
  • Rule of thirds + gaussian distribution = instant focal points
  • Margins (even just 8%) make everything feel more professional
  • Negative space should be intentional, not accidental -- design the empty areas
  • Warped grids add organic feel without losing underlying structure
  • The algorithm IS the composition -- structure plus controlled randomness equals art

These composition techniques give you the spatial framework. But spatial layout is only half the story -- what about the visual elements themselves? Letterforms, text, typography as visual material rather than just something you read. Drawing text as paths, distorting it, making words into abstract shapes. That's a whole different kind of composition, and we'll dig into it next time :-)

Sallukes! Thanks for reading.

X

@femdev

Sort:  

Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You published more than 50 posts.
Your next target is to reach 60 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 STOP

Check out our last posts:

Our Hive Power Delegations to the March PUM Winners
Feedback from the April Hive Power Up Day
Hive Power Up Month Challenge - March 2026 Winners List