seeded coastlines, ink relief and an invented language of names —
every line on the map traced back to its algorithm
The concept: a cartographer falls asleep at the drafting table, and whatever word drifts through their mind becomes a world. Type “amber-tide” and you get one realm, forever; type it again next year and the same realm returns. The site is a generative atelier — no stored maps, no data files, no server. One seed string in, one finished parchment chart out, drawn stroke by stroke in HTML canvas — plus an atlas of six recurring dreams, a live name forge and a staged dissection of the terrain pipeline, all running the same code.
Everything below is real code from index.html. Every texture, glyph and name on the maps is procedural — the single exception is one AI-dreamt photograph of the cartographer's desk in the colophon, documented (with its full prompt) in §XI.
The whole piece commits to one metaphor: a chart drawn by hand, at night, by candlelight. The UI is the drafting room (deep umber, brass, a flickering glow); the canvas is the parchment. Two typefaces carry the mood — Cinzel (Roman capitals, used for the cartouche and UI headings) and IM Fell English (a digitisation of 17th-century type, used for every label on the map and all body text, italics included).
A second full palette — the Night chart — swaps parchment for indigo and ink for silver, and replaces paper speckles with a star field. Because all geometry lives in a world object and all color lives in a pal object, switching palettes re-renders the identical map in a different material. That separation (data vs. paint) is the single most useful architectural decision in the piece.
Determinism is the craft here: the same seed must always dream the same world. The seed string is hashed with xmur3 and fed to a mulberry32 PRNG. Every subsystem that needs randomness gets its own stream, derived from the seed plus a suffix — so re-rendering labels can never desynchronise the terrain.
// hash a string into 32 bits, then run a tiny fast PRNG on it
function xmur3(str){
let h = 1779033703 ^ str.length;
for(let i=0;i<str.length;i++){
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return () => { /* …avalanche, returns uint32 */ };
}
const rngFrom = s => mulberry32(xmur3(s)());
rngFrom(seed) // terrain
rngFrom(seed+'|names') // naming language
rngFrom(seed+'|glyphs') // mountain / tree placement
rngFrom(seed+'|labels') // label layout
Elevation is a hand-rolled 2D simplex noise (≈40 lines, seeded by shuffling the permutation table with the PRNG) summed into fractal Brownian motion. Two tricks stop it looking like generic noise:
1 − |noise| produces sharp crests, added at 28% strength so mountain chains form lines rather than dots.const qx = nWarp.fbm(u*1.6+3.1, v*1.6+1.7, 4); // warp field x
const qy = nWarp.fbm(u*1.6+7.9, v*1.6+5.3, 4); // warp field y
let e = nElev.fbm(u*1.9 + qx*0.85, v*1.9 + qy*0.85, 5);
const ridge = 1 - Math.abs(nElev.noise(u*3.4+9.2, v*3.4+2.6));
e += (ridge - 0.5) * 0.28;
// soft falloff toward the frame so the sea always holds the border
const dEdge = Math.min(x, GW-1-x, y, GH-1-y) / (GH*0.24);
e *= clamp(dEdge,0,1) ** 0.65;
The killer detail: sea level is not a fixed number. The Waterline slider asks for “62% of the map underwater”, and the threshold is read from the sorted elevation histogram. The Peaks and Woods sliders work the same way against land cells only. Whatever the seed, the sliders always do exactly what they promise.
function quantile(arr, q){
const a = Float32Array.from(arr); a.sort();
return a[Math.min(a.length-1, Math.floor(q*a.length))];
}
const sea = quantile(h, prm.water); // waterline slider
const mtnT = quantile(landH, 1-prm.peaks); // peaks slider
The coast is the iso-contour of the height field at sea level, extracted with marching squares: every 2×2 cell of the grid is classified into one of 16 cases by which corners are above sea level, crossing points are found by linear interpolation, and the resulting segments are chained into long polylines by matching endpoints in a hash map. Saddle cases (5 and 10) are disambiguated with the cell's centre value.
const c = (v0>t?8:0)|(v1>t?4:0)|(v2>t?2:0)|(v3>t?1:0);
switch(c){
case 1: segs.push([pc,pd]); break; // only bottom-left is land…
case 5: // saddle: check the cell centre to pick a diagonal
if((v0+v1+v2+v3)/4 > t){ segs.push([pa,pd]); segs.push([pb,pc]); }
else { segs.push([pa,pb]); segs.push([pc,pd]); }
/* …14 more cases */
}
Raw contours look machine-made, so each polyline is smoothed once with Chaikin corner-cutting and then wobbled — every vertex is displaced by low-frequency simplex noise, which is exactly what a slightly unsteady drawing hand does:
const wobble = pts => pts.map(p => [
gx(p[0]) + nWob.noise(gx(p[0])*0.016, gy(p[1])*0.016) * 2.4,
gy(p[1]) + nWob.noise(gx(p[0])*0.016+40, gy(p[1])*0.016+40) * 2.4
]);
The coast is stroked twice: once wide at 10% alpha (ink bleed into the paper fibres), once thin and dark (the pen line). The wave lines that hug the shore — the classic antique-map signature — are just more iso-contours, taken at five successively deeper levels of a blurred copy of the height field, each fainter and thinner than the last.
River sources are sampled from the highest 14% of land. Each river simply walks downhill: at every step it moves to the lowest of its eight neighbours, and it ends when it reaches the sea — or merges into a river that already exists, which is how tributary systems appear for free. Paths shorter than 22 cells are rejected; survivors are double-Chaikin-smoothed and stroked with a width that tapers up from 0.6px at the spring to 2.3px at the mouth.
for(let step=0; step<800; step++){
// pick the lowest unvisited neighbour of 8
if(best < 0) break; // stuck in a hollow — abandon
cur = best; path.push(cur);
if(riverSet[cur]){ ok=true; break; } // join an older river
if(h[cur] <= sea){ ok=true; break; } // reached the coast
}
Antique maps don't shade terrain; they stamp it. The renderer walks a jittered grid over the canvas and drops one of three hand-drawn glyphs depending on the cell underneath: a peaked stroke with hatching on its right flank for mountains, a single arc for hills, a broken circle with a stem for trees. Glyphs are sorted by y before drawing so they overlap like a drawing, near over far. Cells within two steps of water stay clean, which keeps the coastline crisp.
function glyphMountain(ctx,x,y,s,r){
const pk = y - s*(1+r*0.35); // jittered peak
ctx.moveTo(x-s, y);
ctx.quadraticCurveTo(x-s*0.3, y-s*0.55, x, pk); // left slope
ctx.quadraticCurveTo(x+s*0.35, y-s*0.5, x+s, y); // right slope
ctx.stroke();
// three short hatch strokes shade the right flank
}
Names are built from syllables — onset, vowel, optional coda — with doubled letters collapsed and lengths clamped to something pronounceable. Feature types then wrap the root in period costume: seas become “Gulf of Doreth”, ranges become “The Vorlan Teeth”, coastal towns take Anglo-Saxon suffixes like -haven, -wick, -mouth. The realm title itself adapts to the geography: one dominant landmass is a Realm, scattered land is an Archipelago.
const on = ['b','br','cr','dr','gr','mor','sel','th','thal','vor', …];
const vw = ['a','e','i','o','u','ae','ai','ea','ia','ei','y'];
const cd = ['','l','n','r','s','th','m','nd','rn','st','sk'];
function syl(){ return pick(on)+pick(vw)+(rng()<0.55 ? pick(cd) : ''); }
realm(r, share){ // geography decides the title
if(share > 0.55) return 'The Realm of '+r;
if(share > 0.32) return 'The '+r+' Dominion';
return 'The '+r+' Archipelago';
}
Where do labels go? Each landmass and sea keeps its pole of inaccessibility — the cell furthest from any coast, found with a multi-source BFS distance field. Landmass names arch along a shallow circle (each letter individually rotated on the arc); seas get wide-tracked italics; every label is stroked first in parchment colour as a halo so it stays legible over glyphs. A simple rectangle-overlap test lets town labels try four positions around their dot and give way gracefully.
The paper itself is four cheap layers: a diagonal two-stop gradient, an fBm mottle rendered at 160×114 and stretched (blur for free), nine radial-gradient stains plus one faint cup-ring, and ~650 fibre speckles. The night palette swaps the speckles for a 420-star field with seven cross-glint stars.
The map is rendered once into six offscreen canvases — paper, wash, coast, relief, labels, vignette — and the visible canvas only ever composites them. That makes the entrance animation almost free: a single requestAnimationFrame timeline fades the layers in on staggered windows, while the coast draws itself — each polyline stroked up to a growing fraction of its points, like ink flowing from a pen:
const washA = ease(sub(p, 0.22, 0.60)); // colour washes bloom
const coastP = ease(sub(p, 0.00, 0.55)); // coastline draws itself
const reliefA= ease(sub(p, 0.42, 0.78)); // mountains are stamped
const labelA = ease(sub(p, 0.60, 1.00)); // names settle last
for(const l of world.coast){
const n = Math.max(2, Math.ceil(l.length * coastP));
// stroke only the first n points of each coastline
}
If the visitor prefers reduced motion, the timeline is skipped entirely and the finished chart appears at once. Exporting is the same composite drawn to a fresh canvas and handed to toDataURL('image/png') — the print you take is pixel-identical to the chart on the desk.
The map alone is an instrument; the page around it makes it a place. Below the drafting table the site continues into four scroll sections, all inheriting the same parchment-and-ink system, wired together with a sticky nav of anchor links (Atelier · Atlas · Naming · Terrain · Colophon).
Six curated seeds (amber-tide, raven-psalm, frost-meridian, gilded-monsoon, salt-vesper, hollow-aurora) are engraved as clickable plates. Nothing is pre-rendered: an IntersectionObserver with an 900px margin waits until the section approaches, then builds each world with the very same buildWorld + renderLayers pipeline as the master map, composites the six layers into a 560×399 thumbnail, and discards the buffers — one plate per tick so the main thread never stalls. Clicking a plate loads its seed into the atelier, re-dreams the master sheet, and smooth-scrolls you back up to watch the ink flow.
const step = () => {
const w = buildWorld(a.seed, PLATE_PRM);
const L = renderLayers(w, PALS.day);
for(const key of ['paper','wash','coast','relief','labels','vig'])
c2.drawImage(L[key].c, 0, 0, 560, 399); // downscale composite
a.el.querySelector('.ptitle').textContent = w.realmTitle;
setTimeout(step, 60); // one plate per tick
};
The naming section explains the grammar in four annotated cards — and then proves it with a live forge: five dice (Realm, Isle, Sea, Range, Port) call the very same makeNamer the maps use, stamping each result into the display with an ink-blur keyframe and logging the last six strikes underneath.
“How a Word Becomes a World” renders the seed salt-vesper four times at four depths of the pipeline: the raw height-field (grid values mapped umber→parchment through putImageData), the land/sea mask after the quantile flood (BFS shore distance tinting the shallows), the bare ink skeleton (coast + rivers + topo contours on blank parchment), and the finished annotated plate. All four reuse the same world object — the stages are honest cross-sections, not illustrations.
GSAP + ScrollTrigger (CDN) drive the entrances: every .rv block rises 40px as it crosses 88% of the viewport, atlas plates stagger by column, and the desk photograph drifts ±6% inside its frame for a slow parallax. Every tween ends with clearProps so the CSS hover transforms take back over, everything is transform/opacity only, and the whole module is skipped when prefers-reduced-motion is set — the content is never hidden by default, so the page degrades to plain visibility if the CDN fails.
The colophon holds a single AI-generated image — a candlelit portrait of the desk itself, framed as “the only plate not drawn by the algorithm.” It was art-directed to sit inside the site's exact palette (brass #c99c4a, parchment #e9dab6, deep umber shadows). The full prompt:
xmur3 + mulberry32); derive one sub-stream per subsystem.prefers-reduced-motion, and let the user take the print.| Library / asset | Purpose |
|---|---|
| Vanilla Canvas 2D | all map rendering — master sheet, atlas plates, terrain stages |
| GSAP 3 + ScrollTrigger (CDN) | scroll reveals, plate staggers, desk-image parallax |
| Google Fonts: Cinzel | Roman capitals — cartouche, compass, UI |
| Google Fonts: IM Fell English | 17th-century book face — map labels, body text |
| assets/desk.jpg (AI, fal.ai) | the single colophon image; prompt in §XI |