p5.js Expert Article 9

Procedural World Generation

Generate infinite terrain, city layouts, vegetation, weather systems, and chunk-based scrolling worlds.

⏱ 28 min read procedural world generation terrain noise chunks infinite

The core principle: noise-sampled fields

Most procedural world generation layers multiple noise() calls to create different scales of variation:

function heightAt(x, y) {
  let continent = noise(x * 0.001, y * 0.001) * 1.0;  // large-scale landmass
  let region    = noise(x * 0.005, y * 0.005) * 0.5;  // medium hills
  let detail    = noise(x * 0.02,  y * 0.02)  * 0.2;  // small rocky detail
  let fine      = noise(x * 0.08,  y * 0.08)  * 0.05; // tiny surface texture

  return continent + region + detail + fine;  // 0 to ~1.75
}

Heightmap terrain

let SEED = 1337;
let W = 200, H = 150;
let heightmap;

function setup() {
  createCanvas(800, 600);
  noiseSeed(SEED);

  heightmap = new Float32Array(W * H);
  for (let x = 0; x < W; x++) {
    for (let y = 0; y < H; y++) {
      heightmap[x + y * W] = heightAt(x, y);
    }
  }
}

const SEA_LEVEL = 0.45;
const SAND      = 0.50;
const GRASS     = 0.65;
const ROCK      = 0.80;

function draw() {
  loadPixels();
  let cw = width / W, ch = height / H;

  for (let x = 0; x < W; x++) {
    for (let y = 0; y < H; y++) {
      let h = heightmap[x + y * W];
      let col = biomeColor(h);
      let px = x * cw, py = y * ch;

      for (let dx = 0; dx < cw; dx++) {
        for (let dy = 0; dy < ch; dy++) {
          let i = ((px + dx) + (py + dy) * width) * 4;
          pixels[i]   = red(col);
          pixels[i+1] = green(col);
          pixels[i+2] = blue(col);
          pixels[i+3] = 255;
        }
      }
    }
  }
  updatePixels();
}

function biomeColor(h) {
  if (h < SEA_LEVEL)  return color(20, 60, 140);   // deep water
  if (h < SAND)       return color(200, 190, 140);  // beach
  if (h < GRASS)      return color(60, 130, 50);    // grass
  if (h < ROCK)       return color(100, 90, 80);    // rock
  return color(240, 240, 255);                      // snow
}

Chunk-based infinite world

Divide the world into fixed-size chunks. Only generate and render chunks near the camera:

const CHUNK_SIZE = 64;
let chunks = new Map();
let camX = 0, camY = 0;

function chunkKey(cx, cy) {
  return `${cx},${cy}`;
}

function getOrCreateChunk(cx, cy) {
  let key = chunkKey(cx, cy);
  if (!chunks.has(key)) {
    chunks.set(key, generateChunk(cx, cy));
  }
  return chunks.get(key);
}

function generateChunk(cx, cy) {
  let tiles = [];
  for (let lx = 0; lx < CHUNK_SIZE; lx++) {
    for (let ly = 0; ly < CHUNK_SIZE; ly++) {
      let wx = cx * CHUNK_SIZE + lx;
      let wy = cy * CHUNK_SIZE + ly;
      tiles.push({
        type: tileType(heightAt(wx * 0.05, wy * 0.05)),
        variant: floor(noise(wx * 0.3, wy * 0.3) * 4)
      });
    }
  }
  return tiles;
}

function draw() {
  background(10);

  // Camera movement
  if (keyIsDown(LEFT_ARROW))  camX -= 2;
  if (keyIsDown(RIGHT_ARROW)) camX += 2;
  if (keyIsDown(UP_ARROW))    camY -= 2;
  if (keyIsDown(DOWN_ARROW))  camY += 2;

  let viewChunkX = floor(camX / CHUNK_SIZE);
  let viewChunkY = floor(camY / CHUNK_SIZE);
  let viewRadius = 3;

  for (let cx = viewChunkX - viewRadius; cx <= viewChunkX + viewRadius; cx++) {
    for (let cy = viewChunkY - viewRadius; cy <= viewChunkY + viewRadius; cy++) {
      let chunk = getOrCreateChunk(cx, cy);
      renderChunk(chunk, cx, cy);
    }
  }

  // Unload distant chunks
  if (chunks.size > 200) {
    let toDelete = [...chunks.keys()].slice(0, 50);
    toDelete.forEach(k => chunks.delete(k));
  }
}

City layout generation

Voronoi-based district partitioning:

function generateCity(numDistricts) {
  // Random district seeds
  let seeds = Array.from({ length: numDistricts }, () => ({
    x: random(width),
    y: random(height),
    type: random(['residential', 'commercial', 'industrial', 'park'])
  }));

  // For each cell in the grid, find the nearest seed (Voronoi)
  return function(x, y) {
    let nearest = seeds.reduce((best, s) => {
      let d = dist(x, y, s.x, s.y);
      return d < best.d ? { d, type: s.type } : best;
    }, { d: Infinity, type: null });
    return nearest.type;
  };
}

// Usage
let cityFn = generateCity(20);

function drawCity() {
  let blockSize = 20;
  for (let x = 0; x < width; x += blockSize) {
    for (let y = 0; y < height; y += blockSize) {
      let district = cityFn(x, y);
      fill(districtColor(district));
      noStroke();
      rect(x + 1, y + 1, blockSize - 2, blockSize - 2);
    }
  }
}

Vegetation placement

Use density maps + rejection sampling:

function placeVegetation(heightmap, densityMap, numAttempts) {
  let plants = [];

  for (let i = 0; i < numAttempts; i++) {
    let x = random(width);
    let y = random(height);
    let h = sampleHeightmap(heightmap, x, y);
    let d = sampleHeightmap(densityMap, x, y);

    // Only place in valid height range and pass density test
    if (h > GRASS && h < ROCK && random() < d) {
      plants.push({
        x, y,
        type:  h < 0.70 ? 'tree' : 'shrub',
        scale: 0.8 + random(0.4)
      });
    }
  }

  return plants;
}

Weather systems

Layer weather on top of terrain:

function weatherAt(wx, wy, time) {
  let cloudDensity = noise(wx * 0.003 + time * 0.0002, wy * 0.003);
  let precipitation = cloudDensity > 0.6 ? (cloudDensity - 0.6) * 2.5 : 0;
  let wind = {
    x: map(noise(wx * 0.002, wy * 0.002, time * 0.001), 0, 1, -1, 1),
    y: map(noise(wx * 0.002 + 100, wy * 0.002, time * 0.001), 0, 1, -0.5, 0.5)
  };
  return { cloudDensity, precipitation, wind };
}

Key takeaways

  • Layer multiple noise octaves (continent + region + detail + fine) for realistic height variation
  • Biome classification is just a series of height thresholds applied to the heightmap
  • Chunk-based worlds generate and cache tiles on demand; unload distant chunks to manage memory
  • Voronoi partitioning from random seeds creates natural-looking district and region layouts
  • Vegetation placement uses height constraints + density maps + rejection sampling
  • Weather systems are simply additional noise layers sampled over world coordinates + time