01 / THE PLAN
A world.
One rule at a time.
You’re going to understand how this map is built, then change it yourself.
- Make a grid of cells.
- Give each cell a height.
- Turn heights into terrain.
- Draw the terrain and connect the controls.
Read one slide, make a prediction, then try its controls. Use ← / → or the buttons below. You only need basic variables, functions, arrays, and loops; we’ll explain their jobs as we go.
Your files: dist/world.mjs + dist/app.mjs
02 / COORDINATES
Start with squares.
A tile has a column x and row y. The top-left tile is (0, 0). Move right: x grows. Move down: y grows.
for (let y = 0; y < SIZE; y++) {
for (let x = 0; x < SIZE; x++) {
// Make one tile at (x, y).
}
}
The inner loop finishes one row. The outer loop moves to the next row. Our real map uses SIZE = 100: 100 × 100 = 10,000 tiles. This demo uses 10 × 10 so you can see the cells.
Tap a square. Can you predict the bottom-right tile’s coordinates before tapping it?
Check your prediction
It’s (9, 9) in this demo, or (99, 99) in the real map. Counting starts at zero.
Read: the two loops inside generateWorld()
03 / DATA → PICTURE
The world is data first.
The generator returns an array of objects. Canvas turns those objects into colored rectangles. These are two separate jobs.
// world.mjs — make the data
tiles.push({ height, terrain });
// app.mjs — draw the data
const x = i % SIZE;
const y = Math.floor(i / SIZE);
ctx.fillStyle = COLORS[terrain];
ctx.fillRect(x * tileSize, y * tileSize,
tileSize, tileSize);
i is an array position. % gives the remainder: positions 0–99 belong to row 0; position 100 starts row 1. The actual renderer also adds offsets to center the map.
If you change a terrain color, do the tile’s height and terrain number change?
Check your prediction
No. Rendering changes the appearance. Generation changes the data. This separation lets us test the generator without a browser.
04 / REPEATABILITY
A seed is the starting key.
We convert the seed text into a number with hashSeed(). The noise function uses that number to produce consistent values at each coordinate.
const key = hashSeed(seed);
const world = generateWorld(seed, settings);
You don’t need to memorize the bit operations in the hash. Its job is simple: the same text gives the same number. It’s a deterministic hash, not a cryptographic security tool.
Change the seed to “hello”, then back to “little-island”. The exact map comes back when the rules also match.
A seed doesn’t guarantee uniqueness: two texts can share a hash. It also won’t preserve a map if you change the generation algorithm.
Read: hashSeed() in world.mjs
05 / THE PROBLEM WITH RANDOM
Random tiles make static.
Give every tile an independent random height and its neighbors have no reason to match. You get speckles instead of hills.
// Concept: unrelated values for each tile.
height = random();
// Better for terrain: related values in space.
height = noise(x / scale, y / scale, key);
Switch between independent random values and smooth noise. Both are repeatable in this demo, but only one produces connected shapes.
In smooth noise, neighboring tiles tend to have similar brightness. Bright = high. Dark = low.
The random comparison is a teaching demo; the game uses smooth noise.
06 / HOW NOISE WORKS
Blend between corners.
Our code uses value noise. Imagine a coarse grid where each corner gets a repeatable number. A point inside a cell blends the four corner values.
- Find its cell:
Math.floor(x),Math.floor(y). - Find how far it is across and down that cell.
- Blend the top two corners and the bottom two.
- Blend those two results vertically.
mix(a, b, t) = a + (b - a) * t
smooth(t) = t * t * (3 - 2 * t)
mix moves from a to b. smooth eases the movement at cell boundaries. That avoids abrupt slope changes between cells.
Move the point across the demo cell. At the center, predict the blend of 0.1, 0.9, 0.4, and 0.7.
Check your prediction
0.525. Halfway blends top = 0.5, bottom = 0.55, then halfway between them = 0.525.
07 / THE SIZE OF FEATURES
Scale changes the spacing.
A noise cell can span many map tiles. Dividing x and y by a larger number makes us move through the noise more slowly.
noise(x / scale + 17,
y / scale + 29, key)
At scale 10, moving 10 map tiles moves roughly one noise cell. At scale 40, it takes 40 tiles. Bigger scale → broader hills and valleys.
The +17 and +29 shift where we sample the field. They are fixed offsets, not extra randomness.
Compare scale 8 and 50 with the same seed. Which one makes more small features?
Check your prediction
Scale 8. Smaller divisors make the noise change faster across the map.
08 / LAYERS
Big shapes + small bumps.
One noise field makes broad terrain. A second field adds finer detail. Their weights control how much each contributes.
height = 0.27
+ broadNoise * 0.55
+ detailNoise * roughness;
The detail samples use x / 8 and y / 8. key ^ 12345 gives that layer a different deterministic pattern. The constants 0.27 and 0.55 set the base height and broad layer’s strength.
Set roughness to zero, then raise it. Watch the small bumps appear without replacing the broad shapes.
Our detail noise ranges from 0 to 1, so it also raises average height. This knob affects the amount of land as well as its texture.
09 / MAKING AN ISLAND
Lower the edges.
Noise alone doesn’t know where the map ends. We subtract more height as a tile gets farther from the center.
distance = Math.hypot(
(x - 49.5) / 50,
(y - 49.5) / 50
);
height -= distance ** 2.5 * falloff;
Math.hypot(a, b) calculates √(a² + b²). Distance is near zero at the center and larger near the edges. The power makes the drop gentler in the middle and stronger outside.
Compare falloff 0 and 0.75. Watch the border darken. Zero allows land to reach the map boundary.
There is no “island image” underneath. It’s noise with a distance penalty.
10 / CLASSIFICATION
Numbers become terrain.
Each height passes through rules in order. The first matching condition decides that tile’s terrain.
if (height < seaLevel - 0.09) → deep water
else if (height < seaLevel) → shallow water
else if (height < seaLevel + beach) → sand
else if (height > mountain) → rock
else → grass or forest
This is pseudocode for the conditions in generateWorld(). The real function stores terrain IDs 0–5; COLORS turns those IDs into colors.
When sea level rises, do the heights change, or does the water cover existing heights?
Check your prediction
The heights stay the same. Only classification changes. Beach width is a height interval, not a fixed number of tiles; a gentle slope produces a wider beach.
11 / EXTRA RULES
Forest has its own field.
After water, beach, and mountain checks, eligible land becomes forest if another noise field passes a threshold.
noise(x / 10, y / 10, key ^ 9876)
>= 1 - forest
Forest 0.45 means “accept values at least 0.55”. It doesn’t promise exactly 45% forest: the noise distribution and eligible terrain affect the result.
Set forest to 0, then 1. Next, lower the mountain threshold. Why do forests disappear from some high ground?
Check your prediction
The mountain condition comes before the forest condition. High ground becomes rock before it gets a chance to become forest.
12 / THE INTERFACE
The HUD feeds the rules.
The sliders don’t paint the map. They supply numbers to the same generator you’ve been studying.
const settings = Object.fromEntries(
controls.map(input => [
input.id, Number(input.value)
])
);
world = generateWorld(seed, settings);
draw();
HTML input values are strings. Number() converts them. Object.fromEntries() makes an object such as {seaLevel: 0.43}.
Open the world link above. Move one slider while keeping the seed fixed. Then reset the rules. Resizing the screen only redraws the existing world.
Read: render(), draw(), and the input listeners in app.mjs
13 / CHECK YOUR WORK
Test a rule, not a picture.
A map can look fine while its logic is broken. Our small tests check properties that must hold.
assert.deepEqual(
generateWorld('hello'),
generateWorld('hello')
);
assert.equal(
generateWorld('hello', {beach: 0})
.counts[2], 0
);
We also check that a higher sea level cannot reduce water coverage, changing forest doesn’t change heights, and every HUD control agrees with the generator’s defaults.
Run npm test before and after an edit. If you change a default, update its slider too; the test catches a mismatch.
Read: test.mjs. No browser is needed for these generator tests.
14 / YOUR FIRST EDITS
Now change it yourself.
Open the project folder and run this:
cd /home/ubuntu/projects/island-lab
npm start
# Open http://localhost:8080
If the server runs on the VPS, use your SSH port forwarding to reach it on your own computer. The deployed world is always available through the link above.
- Change a color. Edit
COLORS[2]inworld.mjs. Refresh. Only beaches should look different. - Change the island shape. Replace
2.5with1in the distance penalty. Predict which part sinks more. Compare with the same seed. - Change the detail size. Change both
x / 8andy / 8to divide by 16. The detail bumps should become broader.
Refresh, inspect the result, run npm test, then undo or keep the change. Change one thing at a time. Git pushes to main publish automatically.
Why does power 1 change the shape?
For distances between 0 and 1, d¹ is larger than d²·⁵, so more height is removed inside the island. For distances above 1, the opposite is true. The change can also break the ocean-boundary test; that’s a useful signal to investigate.