Last time I left you standing on a little 400x400 square of terrain, and I made you a promise: a real world doesn't stop at the edge of the screen. We'd built a whole tiny planet's surface out of two flavours of noise and a colour ramp keyed to altitude - water, sand, grass, rock, snow - and I said the thing that makes it a world rather than a texture is that it keeps going. Allez, today we make good on that. We're going to grow coherent imaginary geography: coastlines that feel like coastlines, biomes that make sense next to each other, rivers that actually run downhill to the sea, forests that grow where forests should, and a world that runs right off the edge of the canvas and keeps on going forever.
This one is a proper capstone for the whole noise arc, and it leans on stuff we built ages ago too. We'll reuse the erosion-droplet idea from episode 57 for the rivers, the "scatter one point per cell" trick from the noise episode for placing trees, and the height-map thinking from last time as the bones of everything. It's all vanilla JavaScript, all field functions, no library, no engine. By the end you'll have a scrollable world map with beaches and mountains and rivers and woodland, and - this is the bit I love - you'll understand why each piece looks right instead of just random. Let me show you what I figured out :-).
We need the same noise engine we've been leaning on, so here it is in compact form. A hash of integer coordinates, smooth interpolation between lattice points, then fractal Brownian motion to stack detail. If any of this is fuzzy, episode 12 built it from scratch and last episode gave it the full workout.
// same noise engine we built back in episode 12: hash -> value noise -> fbm.
function hash2(x, y) {
let h = x * 374761393 + y * 668265263;
h = (h ^ (h >> 13)) * 1274126177;
h = h ^ (h >> 16);
return (h >>> 0) / 4294967295; // deterministic float in [0, 1)
}
function fade(t) { return t * t * (3 - 2 * t); }
function valueNoise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const tl = hash2(xi, yi), tr = hash2(xi + 1, yi);
const bl = hash2(xi, yi + 1), br = hash2(xi + 1, yi + 1);
const u = fade(xf), v = fade(yf);
const top = tl + (tr - tl) * u, bot = bl + (br - bl) * u;
return top + (bot - top) * v;
}
function fbm(x, y, octaves = 6) {
let sum = 0, amp = 0.5, freq = 1, norm = 0;
for (let o = 0; o < octaves; o++) {
sum += amp * valueNoise(x * freq, y * freq);
norm += amp; amp *= 0.5; freq *= 2;
}
return sum / norm; // back into 0..1
}
That is the whole toolbox. Notice fbm(x, y) answers for any coordinate you hand it - there's no grid you can fall off, no array with a last element. Ask it about (3, 5) or (9999999, -42) and it just computes an answer. Hold onto that thought, because it is the single reason infinite worlds are even possible. The world isn't stored anywhere - it's a function you evaluate on demand.
Here's the mental shift that makes everything click. Last time I wrote terrainColor(x, y) where x and y were pixels on the canvas. But the canvas is just a window. The real world lives in its own coordinate space, and the canvas is a camera looking at part of it. So instead of "colour of pixel (px, py)", we think "colour of world point (wx, wy)", and the pixel is wherever the camera happens to be pointing.
// the world is a function of WORLD coordinates, not pixel coordinates.
// the canvas is a camera: it shows a rectangle of the world at some offset + zoom.
const camera = { x: 0, y: 0, zoom: 0.004 }; // pan with x/y, scale with zoom
function pixelToWorld(px, py) {
return {
wx: px * camera.zoom + camera.x,
wy: py * camera.zoom + camera.y,
};
}
That tiny function is the hinge the whole episode swings on. Move camera.x and you pan across a world that was always there. Nothing gets generated ahead of time, nothing gets stored - you just ask the noise function about a different patch of coordinates. See where this is going? An infinite world is nothing more than a function you never stop being able to call.
A single elevation field gives you height, and last time we coloured purely by height. But real geography has a second axis: how wet a place is. A high dry place is a rocky peak; a high wet place is a snowy alpine forest. A low dry place is desert; a low wet place is swamp. So we sample two independent noise fields - one for elevation, one for moisture - and read the biome off the pair. The trick to keeping them independent is just offsetting the moisture field to a totally different region of noise space:
// two independent fields sampled at the SAME world point.
// elevation says how high, moisture says how wet. offset moisture far away
// so the two fields don't secretly correlate.
function elevation(wx, wy) {
return fbm(wx, wy, 6);
}
function moisture(wx, wy) {
return fbm(wx + 1000, wy + 1000, 5); // shifted origin = a different field
}
The + 1000 on moisture is doing quiet but important work. If I sampled the same coordinates without the shift, moisture would rise and fall in lockstep with elevation and every mountain would have identical weather. Nudging it to a distant corner of the noise field decouples them, so now you get dry mountains and wet mountains, deserts and swamps. Two cheap fields, and suddenly the map has genuine variety.
Now the fun part: a lookup that turns an (elevation, moisture) pair into a biome. This is a stripped-down version of what ecologists call a Whittaker diagram - real biomes really do sort out along a temperature/rainfall grid. We handle water first (anything below sea level is ocean regardless of moisture), then split the land by height band and wetness:
// classify a world point into a biome + colour.
// water is decided by elevation alone; land splits by height band, then moisture.
const SEA_LEVEL = 0.42;
function biome(wx, wy) {
const e = elevation(wx, wy);
const m = moisture(wx, wy);
if (e < SEA_LEVEL - 0.06) return { name: "deep", color: [30, 60, 120] };
if (e < SEA_LEVEL) return { name: "shallow", color: [60, 110, 170] };
if (e < SEA_LEVEL + 0.03) return { name: "beach", color: [210, 200, 140] };
if (e > 0.82) return { name: "snow", color: [235, 235, 240] };
if (e > 0.70) return { name: "rock", color: [110, 100, 90] };
// mid elevations: let moisture decide dry vs green
if (m < 0.35) return { name: "desert", color: [205, 180, 110] };
if (m < 0.55) return { name: "grass", color: [90, 150, 70] };
return { name: "forest", color: [45, 105, 55] };
}
Read that top to bottom and it's basically a little decision tree of geography. Below sea level, blue. Just above it, a thin band of sandy beach (that + 0.03 window is what gives coastlines their beaches instead of grass running straight into the sea). Way up high, bare rock then snow. And in the liveable middle band, moisture picks between desert, grassland, and forest. Makes sense, right? Every rule maps to something you've seen in a real landscape.
Time to actually see it. We walk every pixel of the canvas, convert it to a world coordinate through the camera, ask the biome function, and write the colour into a pixel buffer - the same putImageData blit we've used since the pixel-manipulation episode. Because we're going through pixelToWorld, this painter automatically follows the camera around the world.
// paint the visible window of the world into the canvas.
// every pixel -> world coord -> biome colour. panning is free.
function paintWorld(ctx, w, h) {
const img = ctx.createImageData(w, h);
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const { wx, wy } = pixelToWorld(px, py);
const [r, g, b] = biome(wx, wy).color;
const i = (py * w + px) * 4;
img.data[i] = r; img.data[i + 1] = g; img.data[i + 2] = b;
img.data[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
// paintWorld(ctx, 512, 512);
Run that and there's your first real world - islands and continents with sandy shores, green interiors, dry patches, grey highlands with snowcaps. Change camera.x or camera.y by a bit and repaint, and you sail across to somewhere completely new that was sitting there the whole time. That already feels like a map you'd find in a game, and we've barely started.
A map without rivers always looks a bit dead to me, and rivers are where this gets genuinly clever, because you can't just paint blue squiggles - they have to obey gravity or your eye instantly knows they're fake. The honest way to make a river is to do what water does: drop it somewhere high and let it roll downhill until it reaches the sea. This is the exact erosion-droplet idea from episode 57, just used to trace a path instead of carve one.
To roll downhill we need the slope of the terrain, and slope is the gradient - the same finite-difference move we used for curl noise and terrain lighting last time. Sample elevation a hair to each side, subtract, and you get the direction the ground tilts:
// which way is downhill here? the negative gradient of elevation.
// finite differences: sample each side, subtract. (same move as last episode.)
function downhill(wx, wy) {
const e = 0.5;
const gx = elevation(wx + e, wy) - elevation(wx - e, wy);
const gy = elevation(wx, wy + e) - elevation(wx, wy - e);
return { dx: -gx, dy: -gy }; // negative gradient points DOWN the slope
}
Now a droplet. Start it at a high point, and repeatedly step it in the downhill direction, recording every world point it passes through. When it reaches sea level (or stops moving, stuck in a little pit), it's done. The list of points it visited is the river's course:
// trace one river: drop a particle high up, roll it downhill to the sea.
// returns the list of world points along its course.
function traceRiver(wx, wy, maxSteps = 800) {
const path = [];
for (let s = 0; s < maxSteps; s++) {
const e = elevation(wx, wy);
if (e < SEA_LEVEL) break; // reached the ocean, done
path.push({ wx, wy });
const { dx, dy } = downhill(wx, wy);
const len = Math.hypot(dx, dy);
if (len < 1e-6) break; // flat pit, nowhere to flow
wx += (dx / len) * 1.5; // fixed step along the slope
wy += (dy / len) * 1.5;
}
return path;
}
That normalisation (dividing by len) matters - it makes the droplet take even-sized steps regardless of how steep the ground is, so a river crossing a gentle plain moves at the same pace as one plunging down a cliff. Without it, rivers crawl on flat ground and teleport on steep ground, and the paths come out lumpy. When I first wrote this I skipped the normalise and spent twenty minutes wondering why my rivers had the shakes. Clean little contracts, every time.
We seed a bunch of droplets at high, dry starting points scattered across the visible window, trace each one, and draw its path as a blue line over the biome map. Because rivers are drawn after the terrain, they sit on top like ink on a printed map:
// seed rivers from high points in view, trace each, draw them over the map.
function drawRivers(ctx, w, h, count = 40) {
ctx.strokeStyle = "rgba(70, 130, 200, 0.9)";
ctx.lineWidth = 1.5;
for (let i = 0; i < count; i++) {
// pick a start pixel, convert to world, only keep high-ground starts
const px = hash2(i, 7) * w, py = hash2(i, 13) * h;
const { wx, wy } = pixelToWorld(px, py);
if (elevation(wx, wy) < 0.65) continue; // rivers start in the highlands
const path = traceRiver(wx, wy);
if (path.length < 8) continue; // ignore tiny dribbles
ctx.beginPath();
for (let j = 0; j < path.length; j++) {
// world coord back to a pixel for drawing
const sx = (path[j].wx - camera.x) / camera.zoom;
const sy = (path[j].wy - camera.y) / camera.zoom;
j === 0 ? ctx.moveTo(sx, sy) : ctx.lineTo(sx, sy);
}
ctx.stroke();
}
}
Notice I filter starts to elevation > 0.65 so rivers begin in the highlands where real rivers are born, and I throw away any path shorter than 8 steps because those are just puddles, not rivers. The little (world - camera) / zoom at the end is just pixelToWorld run backwards - world coordinate back to a screen pixel so we can draw it. Run this over the biome map and watch thin blue veins wind down from every mountain range toward the coast, branching and pooling exactly the way you'd hope. Nobody drew those paths. Gravity did.
Last thing that turns a map into a place: stuff growing on it. We want trees, but scattered believably - denser in forest, sparse in grassland, none in the desert or the sea. The scatter trick is the one from the Worley episode: put one candidate point per grid cell, jittered inside the cell, so trees never form an obvious grid but also never clump into ugly piles. Then we keep each candidate only if the biome there wants a tree:
// one jittered tree candidate per world grid cell, kept only where it belongs.
// this is the "one point per cell" scatter from the Worley-noise episode.
function treesInView(w, h, cell = 6) {
const trees = [];
// work in world space: figure out which world cells touch the screen
const topLeft = pixelToWorld(0, 0);
const botRight = pixelToWorld(w, h);
const step = cell * camera.zoom; // world size of one cell
for (let wy = topLeft.wy; wy < botRight.wy; wy += step) {
for (let wx = topLeft.wx; wx < botRight.wx; wx += step) {
const gx = Math.floor(wx / step), gy = Math.floor(wy / step);
const jx = (gx + hash2(gx, gy)) * step; // jitter inside the cell
const jy = (gy + hash2(gy, gx)) * step;
const b = biome(jx, jy).name;
// forests dense, grass sparse, everything else bare
const chance = b === "forest" ? 0.9 : b === "grass" ? 0.2 : 0;
if (hash2(gx * 31, gy * 17) < chance) trees.push({ wx: jx, wy: jy });
}
}
return trees;
}
The two-tier thing here is worth pausing on. The placement is deterministic (same cell always jitters its point to the same spot, because it's driven by the hash, not Math.random), and the keep-or-drop is also deterministic (another hash lookup). That means the forest looks identical every time you pan away and pan back - no trees flickering in and out, no popping. Determinism is what makes an infinite procedural world feel solid rather than dreamlike. It was always there and it'll always be there.
Then drawing them is easy - a little dark dot per tree, converted from world space to screen:
// draw each tree as a small dark mark on top of the terrain.
function drawTrees(ctx, trees) {
ctx.fillStyle = "rgba(20, 60, 30, 0.85)";
for (const t of trees) {
const sx = (t.wx - camera.x) / camera.zoom;
const sy = (t.wy - camera.y) / camera.zoom;
ctx.beginPath();
ctx.arc(sx, sy, 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
Let me stack the layers into a single render call, the way you'd build any map: terrain first, then rivers, then vegetation. Order matters - each layer paints over the one below, just like a real cartographer working from the ground up.
// render the full world: biomes, then rivers, then trees. layered bottom-up.
function renderWorld(ctx, w, h) {
paintWorld(ctx, w, h); // 1. terrain / biome colours
drawRivers(ctx, w, h); // 2. rivers on top
drawTrees(ctx, treesInView(w, h)); // 3. forests on top of that
}
const canvas = document.querySelector("canvas");
canvas.width = 512; canvas.height = 512;
const ctx = canvas.getContext("2d");
renderWorld(ctx, 512, 512);
And there it is - a full imaginary continent. Sandy coastlines wrapping green interiors, deserts fading into grassland fading into deep forest, snowcapped ranges with blue rivers threading down through the trees to the sea. Every piece agrees with every other piece, because they all read from the same two underlying fields. That coherence - the sense that this land could actually exist - is the whole prize. We didn't place a single thing by hand.
Here's the payoff I promised last episode. Because the world is a pure function of coordinates, panning is trivial - and we can go further and chop the world into chunks that generate on demand, exactly like Minecraft and every other infinite-world game do under the hood. A chunk is just a fixed square of world space, and we can generate any chunk from its integer coordinates without ever having generated its neighbours:
// the world tiles into fixed chunks. generate ANY chunk from its (cx, cy) index
// alone - no neighbours needed, because every field is global and seamless.
const CHUNK = 64; // world units per chunk
function generateChunk(cx, cy) {
const originX = cx * CHUNK, originY = cy * CHUNK;
const cells = [];
for (let y = 0; y < CHUNK; y++) {
for (let x = 0; x < CHUNK; x++) {
cells.push(biome(originX + x, originY + y)); // absolute world coords
}
}
return { cx, cy, cells };
}
The reason this works - the reason chunk (5, 2) lines up perfectly with chunk (6, 2) even though we generated them separately with no knowledge of each other - is that both call biome with absolute world coordinates. The noise function is globally consistent, so the seam between two chunks is invisible. This is the deep beauty of building your world as a coordinate function instead of an array: the world is infinite, seamless, and reproducible, and it costs zero memory until you actually look at a piece of it. Load the chunks near the camera, forget the ones far away, and you can wander forever.
fbm(x, y) answers for any coordinate forever, so the canvas becomes a camera looking at a world that was always there. Pan by changing the camera, generate nothing ahead of timeSo that's procedural world generation, and it's honestly one of my favourite payoffs in the whole series, because it braids together so many threads we've been pulling on - noise, gradients, droplets, jittered scatter, height maps - into one thing that feels alive. The single idea I want lodged in your head: coherence comes from shared sources. Every biome, river, and tree on that map agrees with the others because they all read from the same two fields. Build your world as a set of functions over shared coordinates and it will always hang together, no matter how big it grows. Get the biome map painting first, then treat yourself to the rivers - watching them find their way to the sea on the first try is a proper little thrill.
And here's the thread to carry forward. Everything we grew today was natural - land shaped by noise, water shaped by gravity, forests shaped by climate. But people don't only live in wildernesses. They build. Streets, walls, rooms, floor plans - structures with rules and grids and intention, not smooth organic fields. What happens when you point this same generative thinking at things that are designed rather than grown, when the output has to be buildable and deliberate instead of natural? That's a very different kind of procedural generation, and it's exactly where we head next. 't Was plezant to build a whole world with you today - now go pan around yours and find a coastline you like :-).
Sallukes! Thanks for reading.
X