All articles
15 min read

Procedural Generation: How to Create Infinite Worlds With Code

procedural generationalgorithmscreative codinggenerative artgame development

Procedural generation is the art of letting algorithms build worlds. Instead of hand-crafting every tree, every dungeon room, every mountain ridge — you write rules, and the computer produces infinite variations. It's how Minecraft creates endless terrain, how No Man's Sky fills a universe with 18 quintillion planets, and how Lumitree grows a tree of unique micro-worlds from visitor seeds.

This guide covers the core algorithms of procedural generation with working code examples you can run in your browser. No game engine required — just HTML, Canvas, and JavaScript.

1. Noise-based terrain generation

The foundation of most procedural worlds is noise — smooth, pseudo-random values that create organic-looking patterns. Perlin noise and simplex noise are the classics, but even a simple value noise implementation can produce convincing terrain.

The key insight: noise at different frequencies (octaves) creates different scales of detail. Low frequency = broad mountain shapes. High frequency = small rocky details. Layer them together for realistic terrain.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Simple hash-based noise
function hash(x, y) {
  let h = x * 374761393 + y * 668265263;
  h = (h ^ (h >> 13)) * 1274126177;
  return (h ^ (h >> 16)) / 2147483648;
}

function smoothNoise(x, y) {
  const ix = Math.floor(x), iy = Math.floor(y);
  const fx = x - ix, fy = y - iy;
  const sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy);
  const a = hash(ix, iy), b = hash(ix + 1, iy);
  const c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1);
  return a + sx * (b - a) + sy * (c - a) + sx * sy * (d - b - c + a);
}

function fbm(x, y, octaves = 6) {
  let val = 0, amp = 0.5, freq = 1;
  for (let i = 0; i < octaves; i++) {
    val += amp * smoothNoise(x * freq, y * freq);
    amp *= 0.5; freq *= 2;
  }
  return val;
}

// Render terrain heightmap
const img = ctx.createImageData(600, 400);
for (let y = 0; y < 400; y++) {
  for (let x = 0; x < 600; x++) {
    const h = fbm(x * 0.008, y * 0.008);
    const i = (y * 600 + x) * 4;
    if (h < 0.35) { img.data[i] = 30; img.data[i+1] = 100; img.data[i+2] = 180; } // water
    else if (h < 0.4) { img.data[i] = 210; img.data[i+1] = 190; img.data[i+2] = 140; } // sand
    else if (h < 0.65) { img.data[i] = 50; img.data[i+1] = 140 + h * 60; img.data[i+2] = 40; } // grass
    else if (h < 0.8) { img.data[i] = 100; img.data[i+1] = 90; img.data[i+2] = 70; } // rock
    else { img.data[i] = 220; img.data[i+1] = 220; img.data[i+2] = 230; } // snow
    img.data[i+3] = 255;
  }
}
ctx.putImageData(img, 0, 0);

This produces a top-down terrain map with water, beaches, grassland, mountains, and snow caps — all from one noise function with different thresholds. Change the seed (modify the hash constants) and you get a completely different world.

2. Wave function collapse

Wave function collapse (WFC) is one of the most elegant procedural generation algorithms. Inspired by quantum mechanics, it starts with every cell in a "superposition" of all possible tiles, then collapses them one by one, propagating constraints to neighbors. The result: coherent, rule-following patterns from random seeds.

const canvas = document.createElement('canvas');
const SIZE = 20, TILE = 20;
canvas.width = SIZE * TILE; canvas.height = SIZE * TILE;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Tile types: [color, canConnectUp, canConnectRight, canConnectDown, canConnectLeft]
const TILES = [
  { color: '#2d5a27', edges: [0,0,0,0] }, // grass
  { color: '#4a7c3f', edges: [0,1,0,1] }, // horizontal path
  { color: '#4a7c3f', edges: [1,0,1,0] }, // vertical path
  { color: '#4a7c3f', edges: [1,1,1,1] }, // crossroad
  { color: '#4a7c3f', edges: [0,1,1,0] }, // corner NE→S
  { color: '#4a7c3f', edges: [1,1,0,0] }, // corner SE→N
  { color: '#4a7c3f', edges: [1,0,0,1] }, // corner SW→N
  { color: '#4a7c3f', edges: [0,0,1,1] }, // corner NW→S
  { color: '#3b8bba', edges: [0,0,0,0] }, // water
];

// Check if two tiles can be adjacent
function compatible(tileA, tileB, dir) {
  // dir: 0=up, 1=right, 2=down, 3=left
  const opposite = [2, 3, 0, 1];
  return TILES[tileA].edges[dir] === TILES[tileB].edges[opposite[dir]];
}

// Initialize grid: each cell can be any tile
const grid = Array.from({length: SIZE * SIZE}, () => TILES.map((_, i) => i));

function entropy(cell) { return cell.length; }

function collapse() {
  // Find cell with lowest entropy > 1
  let minE = Infinity, minIdx = -1;
  for (let i = 0; i < grid.length; i++) {
    if (grid[i].length > 1 && grid[i].length < minE) {
      minE = grid[i].length; minIdx = i;
    }
  }
  if (minIdx === -1) return false; // done

  // Collapse: pick random tile from possibilities
  const opts = grid[minIdx];
  grid[minIdx] = [opts[Math.floor(Math.random() * opts.length)]];

  // Propagate constraints
  const stack = [minIdx];
  while (stack.length) {
    const idx = stack.pop();
    const x = idx % SIZE, y = Math.floor(idx / SIZE);
    const neighbors = [
      y > 0 ? idx - SIZE : -1, // up
      x < SIZE-1 ? idx + 1 : -1, // right
      y < SIZE-1 ? idx + SIZE : -1, // down
      x > 0 ? idx - 1 : -1, // left
    ];
    for (let dir = 0; dir < 4; dir++) {
      const ni = neighbors[dir];
      if (ni === -1 || grid[ni].length <= 1) continue;
      const before = grid[ni].length;
      grid[ni] = grid[ni].filter(t =>
        grid[idx].some(myT => compatible(myT, t, dir))
      );
      if (grid[ni].length < before) stack.push(ni);
      if (grid[ni].length === 0) grid[ni] = [0]; // fallback
    }
  }
  return true;
}

while (collapse()) {}

// Render
for (let y = 0; y < SIZE; y++) {
  for (let x = 0; x < SIZE; x++) {
    const tile = TILES[grid[y * SIZE + x][0]];
    ctx.fillStyle = tile.color;
    ctx.fillRect(x * TILE, y * TILE, TILE, TILE);
    // Draw path lines
    if (tile.edges.some(e => e)) {
      ctx.strokeStyle = '#d4a574';
      ctx.lineWidth = 4;
      const cx = x * TILE + TILE/2, cy = y * TILE + TILE/2;
      ctx.beginPath();
      if (tile.edges[0]) { ctx.moveTo(cx, cy); ctx.lineTo(cx, y * TILE); }
      if (tile.edges[1]) { ctx.moveTo(cx, cy); ctx.lineTo((x+1) * TILE, cy); }
      if (tile.edges[2]) { ctx.moveTo(cx, cy); ctx.lineTo(cx, (y+1) * TILE); }
      if (tile.edges[3]) { ctx.moveTo(cx, cy); ctx.lineTo(x * TILE, cy); }
      ctx.stroke();
    }
  }
}
ctx.strokeStyle = '#1a1a1a22';
for (let i = 0; i <= SIZE; i++) {
  ctx.beginPath(); ctx.moveTo(i*TILE,0); ctx.lineTo(i*TILE,SIZE*TILE); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(0,i*TILE); ctx.lineTo(SIZE*TILE,i*TILE); ctx.stroke();
}

WFC is powerful because you define local rules (which tiles can be neighbors) and get global coherence for free. It's used in games like Townscaper and tools like Oskar Stålberg's tile-based generators.

3. Cellular automata caves

Cellular automata are the simplest way to generate organic-looking cave systems. Start with random noise, then apply a rule: if a cell has enough "wall" neighbors, it becomes wall; otherwise it becomes floor. After a few iterations, natural-looking caves emerge.

const canvas = document.createElement('canvas');
const W = 80, H = 60, CELL = 8;
canvas.width = W * CELL; canvas.height = H * CELL;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Initialize with ~45% walls
let grid = Array.from({length: W * H}, () => Math.random() < 0.45 ? 1 : 0);

function countNeighbors(g, x, y) {
  let count = 0;
  for (let dy = -1; dy <= 1; dy++) {
    for (let dx = -1; dx <= 1; dx++) {
      if (dx === 0 && dy === 0) continue;
      const nx = x + dx, ny = y + dy;
      if (nx < 0 || nx >= W || ny < 0 || ny >= H) { count++; continue; }
      count += g[ny * W + nx];
    }
  }
  return count;
}

// Run 5 smoothing iterations
for (let iter = 0; iter < 5; iter++) {
  const next = new Array(W * H);
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const walls = countNeighbors(grid, x, y);
      next[y * W + x] = walls >= 5 ? 1 : (walls <= 2 ? 1 : 0);
    }
  }
  grid = next;
}

// Render cave system
for (let y = 0; y < H; y++) {
  for (let x = 0; x < W; x++) {
    ctx.fillStyle = grid[y * W + x] ? '#2c2c3a' : '#8b7355';
    ctx.fillRect(x * CELL, y * CELL, CELL, CELL);
    if (!grid[y * W + x]) {
      // Add subtle floor texture
      ctx.fillStyle = 'rgba(0,0,0,0.05)';
      if ((x + y) % 3 === 0) ctx.fillRect(x*CELL+2, y*CELL+2, 3, 3);
    }
  }
}

The magic parameter is the birth/survival threshold. At 5 neighbors = wall, you get smooth caves. Lower thresholds produce more open spaces; higher thresholds create tighter tunnels. This is the same algorithm behind Conway's Game of Life — just with different rules.

4. BSP dungeon generation

Binary Space Partitioning (BSP) generates dungeons by recursively splitting a rectangle into smaller rooms, then connecting them with corridors. It guarantees every room is reachable — a property that random placement can't promise.

const canvas = document.createElement('canvas');
canvas.width = 640; canvas.height = 480;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#1a1a2e'; ctx.fillRect(0, 0, 640, 480);

class BSPNode {
  constructor(x, y, w, h) {
    this.x = x; this.y = y; this.w = w; this.h = h;
    this.left = null; this.right = null; this.room = null;
  }
  split(depth = 0) {
    if (depth >= 4 || this.w < 80 || this.h < 80) {
      // Create room with padding
      const pad = 8;
      const rw = pad + Math.random() * (this.w - pad * 3);
      const rh = pad + Math.random() * (this.h - pad * 3);
      const rx = this.x + pad + Math.random() * (this.w - rw - pad * 2);
      const ry = this.y + pad + Math.random() * (this.h - rh - pad * 2);
      this.room = { x: rx, y: ry, w: rw, h: rh };
      return;
    }
    const horizontal = Math.random() > 0.5;
    if (horizontal) {
      const split = 0.3 + Math.random() * 0.4;
      const h1 = Math.floor(this.h * split);
      this.left = new BSPNode(this.x, this.y, this.w, h1);
      this.right = new BSPNode(this.x, this.y + h1, this.w, this.h - h1);
    } else {
      const split = 0.3 + Math.random() * 0.4;
      const w1 = Math.floor(this.w * split);
      this.left = new BSPNode(this.x, this.y, w1, this.h);
      this.right = new BSPNode(this.x + w1, this.y, this.w - w1, this.h);
    }
    this.left.split(depth + 1);
    this.right.split(depth + 1);
  }
  getRoom() {
    if (this.room) return this.room;
    const l = this.left?.getRoom(), r = this.right?.getRoom();
    return l || r;
  }
}

const root = new BSPNode(0, 0, 640, 480);
root.split();

// Draw rooms and corridors
function draw(node) {
  if (node.room) {
    ctx.fillStyle = '#16213e';
    ctx.fillRect(node.room.x, node.room.y, node.room.w, node.room.h);
    ctx.strokeStyle = '#0f3460';
    ctx.lineWidth = 2;
    ctx.strokeRect(node.room.x, node.room.y, node.room.w, node.room.h);
    return;
  }
  if (node.left && node.right) {
    draw(node.left); draw(node.right);
    // Connect rooms with corridor
    const r1 = node.left.getRoom(), r2 = node.right.getRoom();
    if (r1 && r2) {
      const x1 = r1.x + r1.w/2, y1 = r1.y + r1.h/2;
      const x2 = r2.x + r2.w/2, y2 = r2.y + r2.h/2;
      ctx.strokeStyle = '#e94560';
      ctx.lineWidth = 6;
      ctx.beginPath();
      ctx.moveTo(x1, y1);
      ctx.lineTo(x2, y1); // horizontal first
      ctx.lineTo(x2, y2); // then vertical
      ctx.stroke();
    }
  }
}
draw(root);

// Add room labels
let roomNum = 1;
function label(node) {
  if (node.room) {
    ctx.fillStyle = '#e94560';
    ctx.font = 'bold 14px monospace';
    ctx.textAlign = 'center';
    ctx.fillText('Room ' + roomNum++, node.room.x + node.room.w/2, node.room.y + node.room.h/2);
  }
  if (node.left) label(node.left);
  if (node.right) label(node.right);
}
label(root);

BSP dungeons are everywhere in roguelikes — NetHack, Binding of Isaac, Spelunky. The algorithm is simple but the results feel hand-designed because the spatial partitioning naturally creates varied room sizes and logical connections.

5. L-system plants and structures

L-systems (Lindenmayer systems) generate complex branching structures from simple string rewriting rules. They're perfect for procedural plants, trees, coral, lightning, and any structure with self-similar branching.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 500;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0a0a14'; ctx.fillRect(0, 0, 600, 500);

function lSystem(axiom, rules, iterations) {
  let str = axiom;
  for (let i = 0; i < iterations; i++) {
    str = str.split('').map(c => rules[c] || c).join('');
  }
  return str;
}

// Stochastic tree — slight randomness in angles and lengths
const rules = {
  'F': 'FF',
  'X': 'F-[[X]+X]+F[+FX]-X'
};
const commands = lSystem('X', rules, 5);

ctx.translate(300, 490);
const stack = [];
let angle = -Math.PI / 2; // start pointing up
let len = 4;

ctx.lineWidth = 1;

for (const cmd of commands) {
  const jitter = (Math.random() - 0.5) * 0.15;
  switch (cmd) {
    case 'F':
      const dx = Math.cos(angle + jitter) * len;
      const dy = Math.sin(angle + jitter) * len;
      // Color based on position (trunk → branches → leaves)
      const depth = stack.length;
      if (depth < 3) ctx.strokeStyle = '#5d4037';
      else if (depth < 5) ctx.strokeStyle = '#7cb342';
      else ctx.strokeStyle = '#aed581';
      ctx.lineWidth = Math.max(0.5, 3 - depth * 0.4);
      ctx.beginPath();
      ctx.moveTo(0, 0);
      ctx.lineTo(dx, dy);
      ctx.stroke();
      ctx.translate(dx, dy);
      break;
    case '+': angle += (25 * Math.PI / 180) + jitter; break;
    case '-': angle -= (25 * Math.PI / 180) + jitter; break;
    case '[':
      stack.push({ x: ctx.getTransform().e, y: ctx.getTransform().f, angle, len });
      len *= 0.85;
      break;
    case ']':
      if (stack.length) {
        const state = stack.pop();
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.translate(state.x, state.y);
        angle = state.angle;
        len = state.len;
      }
      break;
  }
}

The stochastic element (random jitter) makes each tree unique while the L-system rules ensure structural coherence. This is how generative design works at its best: deterministic rules plus controlled randomness.

6. Poisson disc sampling

When you need to place objects (trees, rocks, buildings, stars) in a way that looks natural — not too regular, not too clumpy — Poisson disc sampling is the answer. It guarantees a minimum distance between points while maintaining an organic distribution.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#1b2838'; ctx.fillRect(0, 0, 600, 400);

function poissonDisc(width, height, minDist, maxAttempts = 30) {
  const cellSize = minDist / Math.SQRT2;
  const gridW = Math.ceil(width / cellSize);
  const gridH = Math.ceil(height / cellSize);
  const grid = new Array(gridW * gridH).fill(-1);
  const points = [];
  const active = [];

  function gridIdx(x, y) {
    return Math.floor(y / cellSize) * gridW + Math.floor(x / cellSize);
  }

  // Seed point
  const sx = width / 2, sy = height / 2;
  points.push([sx, sy]);
  active.push(0);
  grid[gridIdx(sx, sy)] = 0;

  while (active.length > 0) {
    const idx = Math.floor(Math.random() * active.length);
    const [px, py] = points[active[idx]];
    let found = false;

    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      const angle = Math.random() * Math.PI * 2;
      const dist = minDist + Math.random() * minDist;
      const nx = px + Math.cos(angle) * dist;
      const ny = py + Math.sin(angle) * dist;

      if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;

      const gx = Math.floor(nx / cellSize);
      const gy = Math.floor(ny / cellSize);
      let ok = true;

      for (let dy = -2; dy <= 2 && ok; dy++) {
        for (let dx = -2; dx <= 2 && ok; dx++) {
          const cx = gx + dx, cy = gy + dy;
          if (cx < 0 || cx >= gridW || cy < 0 || cy >= gridH) continue;
          const pi = grid[cy * gridW + cx];
          if (pi !== -1) {
            const [qx, qy] = points[pi];
            if ((nx-qx)**2 + (ny-qy)**2 < minDist**2) ok = false;
          }
        }
      }

      if (ok) {
        const ni = points.length;
        points.push([nx, ny]);
        active.push(ni);
        grid[gridIdx(nx, ny)] = ni;
        found = true;
        break;
      }
    }

    if (!found) active.splice(idx, 1);
  }
  return points;
}

const points = poissonDisc(600, 400, 18);

// Draw as a starfield with varying sizes
points.forEach(([x, y], i) => {
  const size = 1 + Math.random() * 2.5;
  const brightness = 0.4 + Math.random() * 0.6;
  ctx.beginPath();
  ctx.arc(x, y, size, 0, Math.PI * 2);
  ctx.fillStyle = `rgba(200,220,255,${brightness})`;
  ctx.fill();
  // Glow for bright stars
  if (brightness > 0.8) {
    ctx.beginPath();
    ctx.arc(x, y, size * 3, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(200,220,255,0.05)`;
    ctx.fill();
  }
});

// Connect nearby stars into constellations
ctx.strokeStyle = 'rgba(100,150,255,0.1)';
ctx.lineWidth = 0.5;
for (let i = 0; i < points.length; i++) {
  for (let j = i + 1; j < points.length; j++) {
    const dx = points[i][0] - points[j][0];
    const dy = points[i][1] - points[j][1];
    if (dx*dx + dy*dy < 40*40) {
      ctx.beginPath();
      ctx.moveTo(points[i][0], points[i][1]);
      ctx.lineTo(points[j][0], points[j][1]);
      ctx.stroke();
    }
  }
}

Poisson disc sampling is essential for natural-looking object placement. Use it anywhere you'd use Math.random() for positions — forests, particle effects, texture synthesis, map features. The Bridson algorithm above runs in O(n) time, making it practical even for thousands of points.

7. Random walk rivers and paths

A random walk with directional bias generates convincing rivers, cracks, lightning bolts, and winding paths. The trick is controlling the bias: rivers flow downhill, cracks follow material stress, paths seek destinations.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

// Background: simple terrain gradient
const grad = ctx.createLinearGradient(0, 0, 0, 400);
grad.addColorStop(0, '#4a7c3f');
grad.addColorStop(1, '#2d5a27');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 600, 400);

// Generate multiple rivers flowing left to right
function generateRiver(startY, width, color) {
  let y = startY;
  let vy = 0; // vertical velocity for smooth curves

  ctx.beginPath();
  ctx.moveTo(0, y);

  for (let x = 0; x < 600; x++) {
    // Brownian motion with mean reversion
    vy += (Math.random() - 0.5) * 0.8;
    vy *= 0.95; // damping
    vy += (startY - y) * 0.002; // pull toward original y
    y += vy;
    y = Math.max(20, Math.min(380, y));
    ctx.lineTo(x, y);
  }

  // Draw river with width variation
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.stroke();

  // Highlight
  ctx.strokeStyle = 'rgba(255,255,255,0.15)';
  ctx.lineWidth = width * 0.3;
  ctx.stroke();

  return y; // end position
}

// Main river
const endY = generateRiver(200, 12, '#2980b9');
// Tributaries
generateRiver(80, 5, '#3498db');
generateRiver(320, 5, '#3498db');
generateRiver(150, 3, '#5dade2');
generateRiver(260, 3, '#5dade2');

// Add trees using Poisson-like spacing
for (let i = 0; i < 200; i++) {
  const tx = Math.random() * 600;
  const ty = Math.random() * 400;
  const size = 3 + Math.random() * 5;
  ctx.fillStyle = `hsl(${100 + Math.random()*30}, ${50+Math.random()*20}%, ${25+Math.random()*15}%)`;
  ctx.beginPath();
  ctx.arc(tx, ty, size, 0, Math.PI * 2);
  ctx.fill();
}

Random walks are the backbone of procedural path generation. Vary the parameters and you get different natural features: high damping = gentle rivers, low damping = lightning bolts, strong mean reversion = roads between cities.

8. Marching squares for contour maps

Marching squares converts a scalar field (like a heightmap) into smooth contour lines or filled regions. It's how topographic maps, metaballs, and isosurface visualizations work.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');

ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, 600, 400);

// Hash noise for the scalar field
function hash(x, y) {
  let h = x * 374761393 + y * 668265263;
  h = (h ^ (h >> 13)) * 1274126177;
  return (h ^ (h >> 16)) / 2147483648;
}
function smooth(x, y) {
  const ix = Math.floor(x), iy = Math.floor(y);
  const fx = x - ix, fy = y - iy;
  const sx = fx*fx*(3-2*fx), sy = fy*fy*(3-2*fy);
  const a = hash(ix,iy), b = hash(ix+1,iy);
  const c = hash(ix,iy+1), d = hash(ix+1,iy+1);
  return a + sx*(b-a) + sy*(c-a) + sx*sy*(d-b-c+a);
}
function field(x, y) {
  return smooth(x*0.02, y*0.02) + 0.5*smooth(x*0.04, y*0.04) + 0.25*smooth(x*0.08, y*0.08);
}

// Draw filled contour bands
const STEP = 4;
const thresholds = [0.3, 0.45, 0.6, 0.75, 0.9, 1.05, 1.2];
const colors = ['#0c2461', '#1e3799', '#4a69bd', '#6a89cc', '#82ccdd', '#b8e994', '#f6e58d'];

for (let y = 0; y < 400; y += STEP) {
  for (let x = 0; x < 600; x += STEP) {
    const v = field(x, y);
    let colorIdx = 0;
    for (let t = 0; t < thresholds.length; t++) {
      if (v > thresholds[t]) colorIdx = t;
    }
    ctx.fillStyle = colors[colorIdx];
    ctx.fillRect(x, y, STEP, STEP);
  }
}

// Draw contour lines using marching squares
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 1;

for (const threshold of thresholds) {
  for (let y = 0; y < 400 - STEP; y += STEP) {
    for (let x = 0; x < 600 - STEP; x += STEP) {
      const tl = field(x, y) > threshold ? 1 : 0;
      const tr = field(x+STEP, y) > threshold ? 1 : 0;
      const br = field(x+STEP, y+STEP) > threshold ? 1 : 0;
      const bl = field(x, y+STEP) > threshold ? 1 : 0;
      const config = tl * 8 + tr * 4 + br * 2 + bl;

      if (config === 0 || config === 15) continue;

      // Interpolate edge crossings
      function lerp(v1, v2) {
        const a = field(...v1), b = field(...v2);
        const t = (threshold - a) / (b - a);
        return [v1[0] + t*(v2[0]-v1[0]), v1[1] + t*(v2[1]-v1[1])];
      }

      const top = lerp([x,y],[x+STEP,y]);
      const right = lerp([x+STEP,y],[x+STEP,y+STEP]);
      const bottom = lerp([x,y+STEP],[x+STEP,y+STEP]);
      const left = lerp([x,y],[x,y+STEP]);

      ctx.beginPath();
      const lines = {
        1:[left,bottom], 2:[bottom,right], 3:[left,right],
        4:[top,right], 5:null, 6:[top,bottom], 7:[top,left],
        8:[top,left], 9:[top,bottom], 10:null, 11:[top,right],
        12:[left,right], 13:[bottom,right], 14:[left,bottom]
      };
      const l = lines[config];
      if (l) {
        ctx.moveTo(l[0][0], l[0][1]);
        ctx.lineTo(l[1][0], l[1][1]);
        ctx.stroke();
      }
    }
  }
}

Marching squares is a 2D version of marching cubes (used in 3D). It's perfect for generating topographic maps, weather visualizations, and the blobby metaball effects you see in many creative coding sketches.

Combining techniques: the procedural pipeline

Real procedural generation systems don't use one algorithm — they layer many. A typical open-world game might use:

  1. Noise-based terrain for the base heightmap
  2. Cellular automata for cave systems underground
  3. BSP for dungeons and building interiors
  4. L-systems for vegetation
  5. Poisson disc sampling for object placement
  6. Random walks for rivers and roads
  7. Marching squares/cubes for smooth terrain rendering
  8. Wave function collapse for tiled areas (towns, interiors)

Each layer feeds into the next. Terrain height determines biome. Biome determines which L-system rules generate vegetation. Poisson sampling places trees with appropriate density for that biome. Rivers follow the gradient of the heightmap. Towns appear at river crossings.

Procedural generation beyond games

These techniques aren't just for games. Procedural generation powers:

  • Generative art — every math art piece and geometric pattern is procedurally generated
  • Film VFX — Houdini's node-based system is procedural generation for visual effects
  • Architectureparametric buildings designed by algorithms
  • Music — algorithmic composition, infinite background music
  • Testing — procedural test data generation for software QA
  • Fashion — generative textile patterns and garment shapes

The philosophy is always the same: define rules, add randomness, generate infinite variations.

Build your own procedural world

The examples above are complete — copy any of them into an HTML file and they'll run. But the real fun starts when you combine them. Try feeding noise terrain into marching squares. Plant L-system trees using Poisson disc sampling. Carve cellular automata caves through a BSP dungeon.

That's exactly the spirit behind Lumitree: each micro-world on the tree is a procedurally generated artifact, built under extreme constraints (50KB maximum), using the same algorithms described here. Plant a seed, and the system generates something no one has seen before — a tiny world born from rules and randomness.

The code examples in this guide use only vanilla JavaScript and Canvas — no frameworks, no build tools. That's intentional. Procedural generation is about understanding the algorithm, not wrestling with dependencies. Once you understand noise fields, cellular automata, and spatial partitioning, you can implement them in any language, any engine, any medium.

Start simple. Pick one algorithm. Run it. Change the parameters. Break it. Fix it. Then combine it with another. That's how procedural worlds grow.

Related articles