Realistic, geology-based procedural world generation for games
Companion file: world_generation_bench.js in this folder, a single-thread plain-JS micro-benchmark of the kernels below (node world_generation_bench.js 256).
Dwarf Fortress world generation, stage by stage
Takeaway
Dwarf Fortress does not paint biomes. It generates six coarse scalar fields (elevation, rainfall, temperature, drainage, volcanism, savagery) on a tiny grid (17 to 257 squares a side) by seeding a coarse mesh with weighted random values and filling it fractally (midpoint-displacement style, with per-axis variance), then derives vegetation and biomes from field thresholds, carves rivers by running many “fake” downhill rivers that dig when stuck, grows lakes along rivers, smooths, then recomputes rain (rain shadow), temperature and vegetation from the final terrain, and finally checks the result against many count thresholds, throwing the whole world away and rerolling if it fails. The world map is small and coarse; detail comes from each square expanding to 16x16 local blocks of 48x48 tiles when you embark.
Cited Findings
Primary description, Tarn Adams (Gamasutra Q&A “Dissecting the development of Dwarf Fortress”, quoted in full on the DF wiki):
- Stage order, in Adams’ words: allocate the map; choose the pole layout; “The basic map field values (elevation, rainfall, temperature, drainage, volcanism, wildness) are seeded along a grid of variable size, respecting various settings (oceans, island sizes, other variances, etc.), and then filled in fractally”; poles vary temperature; pick points for the highest peaks; a first pass checks the map and “attempts to adjust some altitudes to fit the map within the desired parameters if it missed. The world can be rejected at this point if it is unfixable” — Gamasutra Q&A, quoted on DF wiki World generation; DF wiki: World generation
- Next: vegetation (the first derived field) is set from elevation, rainfall, temperature; biome rejection test; “The mid-level elevations are smoothed at this point to make more plains areas, and volcanoes are placed respecting the hot spots in the volcanism field” — same sources.
- Erosion and rivers: “Small oceans are dried out, and it locates edges of mountain sides where it can run test rivers … Many fake rivers flow downward from these points, carving channels in the elevation field if they can’t find a path to the sea. Extreme elevations differences are often smoothed here so that everything isn’t canyons. Ideally we’d use mineral types for that, but we don’t yet. Lakes are grown out at several points along the rivers.” — same sources.
- After rivers: “Elevations are smoothed again from the mountains down to the sea, and the peaks and volcanoes do some local adjustments. Now that the elevations are finalized, it makes adjustments to rainfall based on rain shadows and orographic precipitation. Temperatures are reset based on elevation and rainfall and the dampening effects of forests, and it uses the new values to set the vegetation level one final time. Salinity values are set for the ocean and tiles neighboring the ocean.” — same sources.
- Finally: detect biome regions, name them; add geological and underground layers (“though the geological stuff should really be earlier”); “a final verification process against the parameters”; initial wildlife populations and weather variables. Then history: “Civilizations and caves are placed … there’s a giant zero-player strategy game going on with somewhat loose turn rules and bad AI (but thousands of agents), and history is just a record of that.” — same sources.
Earlier (2008) description, Adams to John Harris:
- Elevation by midpoint displacement; temperature biased by elevation and latitude; rainfall later biased by orographic precipitation and rain shadows; drainage a fractal 0-100 field; salinity 100 at oceans tapering quickly over land; Adams notes midpoint displacement leaves artifacts that erosion later addresses. Biomes from field combinations, e.g. swamp where “rainfall is >=66/100 and drainage is less than 50”. — Gamasutra/Game Developer, “Interview: The Making Of Dwarf Fortress”, John Harris, 27 Feb 2008
- River algorithm (2008): pick mountain bases (squares above an elevation), run temporary paths downhill, digging when stuck, until they reach the ocean; then run real rivers along the best channels, force paths for rivers that fail to reach the sea, bulge out some lakes, apply loop-erasure; flow amounts decide tributaries; rivers are named; invisible brooks added. — same 2008 interview
Parameters (DF wiki, Advanced world generation; DF2014 tokens and defaults/examples):
- World sizes: 17, 33, 65, 129, 257 squares a side (Pocket to Large); each region square = 16x16 local blocks of 48x48 tiles. A 257x257 world is 66,049 squares. “the essential factor for the gen duration is the history”, not the map. — DF wiki: World generation; DF wiki: Advanced world generation
- Field tokens
[FIELD:min:max:xVariance:yVariance], examples:ELEVATION:1:400:401:401(range 0-400),RAINFALL:0:100:200:200,TEMPERATURE:25:75:200:200(range -1000..1000),DRAINAGE:0:100:200:200,VOLCANISM:1:100:200:200,SAVAGERY:1:100:200:200; variance range 0-3200. X and Y variance control how wildly a value can differ between adjacent squares, per axis; setting only one high gives bands; both 0 gives a single value everywhere. — DF wiki: Advanced world generation - The mesh (“weighted ranges”):
[ELEVATION_FREQUENCY:mesh:w0-20:w20-40:w40-60:w60-80:w80-100], e.g.ELEVATION_FREQUENCY:2:1:2:3:4:5; mesh 1=ignore, 2=2x2 … 6=32x32 (limited by world size; finest mesh always makes grid cells 8x8 world squares). Steps: make the grid, set intersections to random values drawn from the five weighted bands, “smooth out the area between the intersection points”, “add noise according to the variance parameters”. Variance large enough swamps the mesh; the effect depends on mesh count, not cell size. — same source. - Thresholds that make biomes: ocean elevation 0-99; land 100-299; mountains 300-400; peaks only at 400; volcano needs volcanism 100; rivers placed only if elevation max >= 104. Biome table (verify-tagged on the wiki): swamp = rain 33-100, drainage 0-32, non-freezing; desert/badland = rain 0-9 (drainage 0-32 sand desert, 33-49 / 50-65 rocky wasteland, 66-100 badlands); forest = rain 66-100, drainage 66-100 (as listed); grassland = rain 0-66, drainage 0-66; hills = rain 0-66, drainage 66-100; tundra = freezing, drainage 0-66; glacier = freezing, drainage ~80-100. High drainage “important for forming hills”; low drainage makes lakes, rivers, swamps and reportedly thicker soil. — same source.
- Orographic precipitation is a toggle (
OROGRAPHIC_PRECIPITATION:1); it can push rainfall outside the min/max; a 2022-reproduced forum report says it only applies where drainage >= 50. — same source. - Poles:
POLE:NONE|NORTH|SOUTH|NORTH_AND_SOUTH|NORTH_OR_SOUTH|NORTH_AND_OR_SOUTH; equator at the opposite edge or the middle; latitude can shift temperature by more than 75 C (wiki marks the exact values “unsure”). — same source. - Erosion and rivers:
EROSION_CYCLE_COUNT:250(0-1000; higher = less jagged, wider rivers; max dissolves mountains into plains and causes rejections);RIVER_MINS:200:400(pre-erosion minimum, post-erosion desired river starts, 0-800; “Extremely high pre-erosion values speed erosion greatly”);PERIODICALLY_ERODE_EXTREMES:1(turns impassable cliffs into ramps). — same source. - Mineral scarcity
MINERAL_SCARCITY:2500(100-100,000; menu presets 50000/10000/2500/500/100). Caverns:CAVERN_LAYER_COUNT:3, openness 0-100 and passage density 0-100 per layer (open expanses vs maze of passages), water 0-100; magma sea and bottom layer toggles. — same source.
The rejection checks:
- “Belt and braces”: parameters steer generation, separate tokens reject and reroll; “certain factors, such as the number of mountain tiles, can’t be determined ahead of time … worlds are generated with parameters which are likely to produce worlds that can support a required number of mountains, and are then checked”. Rejections are logged with
LOG_MAP_REJECTS:YES. — DF wiki: World rejection; DF wiki: World generation - Pure rejection tokens:
REGION_COUNTS:<biome>:<initial squares>:<initial regions>:<final regions>for swamp, desert, forest, mountains, ocean, glacier, tundra, grassland, hills (example defaults for a 257 world: SWAMP 1032:7:6, FOREST 4128:13:12, MOUNTAINS/OCEAN/GRASSLAND/HILLS 8256:…); initial counts are checked before erosion, final region counts after.ELEVATION_RANGES,RAIN_RANGES,DRAINAGE_RANGES,SAVAGERY_RANGES,VOLCANISM_RANGESrequire minimum numbers of low/mid/high squares; “Volcanism not evenly distributed” is a named rejection.SUBREGION_MAX:2750(1-5000) rejects patchwork worlds with too many separate biome regions. — DF wiki: World rejection; DF wiki: Advanced world generation - Placement tokens that reject when unsatisfiable:
VOLCANO_MIN,PEAK_NUMBER_MIN,PARTIAL_OCEAN_EDGE_MIN/COMPLETE_OCEAN_EDGE_MIN(0-4; 4 = island world, 3 = peninsula),RIVER_MINS,MOUNTAIN_CAVE_MIN:25,NON_MOUNTAIN_CAVE_MIN:50,TOTAL_CIV_NUMBER:40,PLAYABLE_CIVILIZATION_REQUIRED:1. Rejection warnings can take “a few hundred” to thousands of rerolls to appear. — DF wiki: World rejection
Sites and civilizations: placed at the start of history (“Civilizations and caves are placed”); civilizations need biomes (dwarves need mountains; elves will not spawn if max temperature is 9; humans need at least 0), so the rejection checks are partly there to guarantee civ placement is possible. — DF wiki: World generation; DF wiki: Advanced world generation
Second-hand (Polygon, 2014, paraphrased by Isegoria): rainfall layer, a separate fractal for mineral strata, temperature fractal, biomes as contiguous regions, then drainage/erosion with rivers carving valleys toward oceans “only after biomes exist”, a salinity pass for deltas and mangroves, region names from four cultures. — Isegoria, 14 Aug 2014, quoting Polygon 23 Jul 2014
On the requested book chapter: “Procedural Generation in Game Design” (2017) lists Tarn Adams as co-editor, not a chapter author; its world chapter is Ch. 10 “Worlds” by Mark R. Johnson (Ultima Ratio Regum). Adams’ own chapter is Ch. 13 “Emergent Narrative in Dwarf Fortress” in the 2019 “Procedural Storytelling in Game Design”, plus “Simulation Principles from Dwarf Fortress” in Game AI Pro 2. — Routledge TOC, Procedural Generation in Game Design; Routledge, Procedural Storytelling in Game Design; O’Reilly, Game AI Pro 2 ch. 41
Inferences
- DF’s grid is tiny. 257x257 is roughly the 256x256 budget case below, and every map stage DF describes (mesh plus fractal fill, threshold biomes, rivers, smoothing, rain shadow) costs tens of milliseconds at that size in JS. DF’s own worldgen time is spent on history, not terrain.
- The “generate, measure, reject, reroll” loop is cheap at this size and is the real secret of DF’s reliability. A phone game can copy it with a cheaper twist: reject on the coarse 64x64 or 128x128 pass (land fraction, mountain count, river count, playable biome present) before running the costly erosion at full size.
- DF’s river carving (“fake rivers dig when stuck”) is an ad hoc stand-in for depression filling; priority-flood (below) does the same job deterministically and in one pass.
- The order matters and is worth copying: climate is computed twice, once roughly to pick biomes and test, and again after erosion so rain shadows follow the final mountains.
Gaps
- No primary source gives DF’s exact fractal routine, roughness falloff or river-dig depth; “midpoint displacement” is Adams’ 2008 word, and the wiki describes “smooth then add noise by variance”. Exact field-to-biome thresholds on the wiki are tagged {{Verify}}.
- The exact publication date of the Gamasutra Q&A could not be confirmed here (the DF wiki cites gamasutra.com/view/news/343859).
- How sites inside a civ are spaced (distance rules) is not documented in the sources reached; it emerges from the history simulation.
Other games and tools, and what each does differently
Takeaway
There are two families. Field-and-threshold generators (Minecraft 1.18+, Terrafirmacraft’s layers, Vintage Story’s strata) evaluate noise and splines per point with no global pass, so they stream forever but cannot carve real drainage: Minecraft’s “rivers” are just the valleys band of a noise. Whole-map simulators (Dwarf Fortress, O’Leary, Azgaar, mapgen4, WorldEngine, Undiscovered Worlds) build a bounded map and then run global passes: depression filling, flow accumulation, wind sweeps. Only those give rivers that join and reach the sea. A bounded phone world (a quest’s island) belongs in the second family. Almost all of them fake erosion cheaply (O’Leary: 5 rounds of a flux-and-slope rule; Azgaar: a one-pass downcut of at most 5 height units; mapgen4: none, just lowering river beds to their tributaries). The physically based tools (WorldEngine’s plate sim) are slow and offline.
Cited Findings
Minecraft 1.18+ (Java), from the Minecraft Wiki and Henrik Kniberg:
- Biomes sit in a six-parameter space: temperature, humidity (vegetation), continentalness, erosion, weirdness (ridges), depth. All but depth are 2D noise; a location takes the biome whose interval box contains it, or the nearest box in 6D. — Minecraft Wiki: World generation
-
Band edges: temperature 5 levels (-1..-0.45..-0.15..0.2..0.55..1); humidity 5 levels (-1..-0.35..-0.1..0.1..0.3..1); continentalness: mushroom fields -1.2..-1.05, deep ocean ..-0.455, ocean ..-0.19, coast ..-0.11, near-inland ..0.03, mid-inland ..0.3, far-inland ..1.0; erosion 7 levels (-1, -0.78, -0.375, -0.2225, 0.05, 0.45, 0.55, 1). Peaks-and-valleys PV = 1 - 3 weirdness - 2 (valleys < -0.85, low < -0.2, mid < 0.2, high < 0.7, peaks). Depth is ~0 at the surface and +1/128 per block down. — Minecraft Wiki: World generation - Terrain: 3D noise gives a density per block (>0 solid); a height bias and base height “squeeze” density with altitude. Continentalness, erosion and PV are mapped through splines to a height offset and a vertical stretch factor (and the same noises drive biomes, so terrain and biome agree). Rivers occur where PV is “Valleys” at low continentalness or high erosion; there is no flow simulation. — Minecraft Wiki: World generation
- Noise caves are part of base terrain: cheese caves are the “white” region of a 3D noise (hollowness controls size, pillars inside); spaghetti caves are the thin band where a noise crosses zero (“the edge of black and white part … becomes air”), noodle caves the same thinner, 1-5 blocks wide; controlled by frequency, hollowness and thickness noises. Old-style “carvers” still cut caves and canyons afterwards. — Minecraft Wiki: Cave; Kniberg’s noise-cave diagram, cited there
-
Aquifers decide what fills every empty block: cells of 16x12x16 blocks, one jittered center per cell, each with a fluid level and type; a block takes the nearest center’s status (a 3D Voronoi, so levels step at cell boundaries), with stone barriers between differing neighbors. Partially flooded levels cluster near Y = 40*floor(Y/40)+20 ±10; lava when level <= Y -10 and lava noise > 0.3 (sampled per 64x40x64); under the sea floor cells are almost always flooded to sea level, inland mostly dry; no aquifers where erosion < -0.225 and depth > 0.9 (the deep dark). — Minecraft Wiki: Cave § Aquifer - Kniberg’s talk on the 1.18 generator is “Minecraft terrain generation in a nutshell”, 6 Feb 2022. — YouTube CSa5O6knuwI, cited on Minecraft Wiki
Martin O’Leary, “Generating fantasy maps” (mewo2/terrain source):
- 16,384 points (
npts), one round of Lloyd relaxation, Voronoi mesh; heightmap = sum of primitives (a random tilted slope, a cone, 50 random “mountains” blobs), 10 rounds of neighbor-averaging, “peaky” (square root sharpening). — mewo2/terrain, terrain.js - Erosion: rate = min(200, 1000·sqrt(flux)·slope + slope²) (river incision plus creep), normalized to the max and scaled by a random amount 0-0.1; run 5 times, filling sinks between. Sinks filled by Planchon-Darboux style iteration with epsilon 1e-5. Sea level set at a random 20-60% height quantile. Flux by sorting cells high-to-low and passing water to the downhill neighbor. — mewo2/terrain, terrain.js
- Cities (15 by default) placed greedily by score = sqrt(flux) (near rivers) plus edge penalties minus 0.02/distance to each existing city; 5 territories grown by a priority-queue flood whose cost includes 100·sqrt(flux) (rivers as borders) and slope. — mewo2/terrain, terrain.js
- The original essay page (mewo2.com/notes/terrain) returned 404 when fetched for these notes; the source is the primary record.
Red Blob Games mapgen4 (Amit Patel, 2018; source on GitHub):
- Points are precomputed: the mesh loads
build/points-5.5.data(Poisson disc, spacing 5.5 in a 1000x1000 square, plus mountain peak points at spacing 35) rather than sampling at runtime; then Delaunator triangulates. The README says the code supports 1 million+ cells (spacing 0.7) but “the rendering code and other parameters are designed to look prettiest around 25k cells.” — mapgen4 mesh.ts; mapgen4 config.js; mapgen4 README - Elevation: painted constraint map (bilinear) + coastline noise; on land, mix low simplex “hills” (there to make rivers meander) with “mountains” from a jittered BFS distance-from-peak field (em = 1 - slope/sharpness·distance), weighted by e². Six simplex octaves are precomputed per triangle once per seed. — mapgen4 map.ts
- Rain: sort regions once by position along the wind vector (cos θ·x + sin θ·y); visit in that order; humidity = mean of upwind neighbors; boundary regions humidity 1; ocean adds evaporation ∝ depth; if humidity > 1 - elevation, the excess times
rain_shadowfalls as orographic rain and is removed; rainfall += raininess·humidity. One pass, O(n) after the sort. — mapgen4 map.ts; Patel, “mapgen4: rainfall”, Sep 2018 - Rivers: a priority queue seeded with ocean triangles walks uphill in elevation order (a priority-flood), giving every triangle a downslope side and a topological order; flow = flow_param·moisture² per land triangle, summed in reverse order; and where a trunk is higher than its tributary its elevation is lowered to match (carving beds). No erosion simulation. — mapgen4 map.ts
- Patel’s own caveat: the straight-line wind is fast but leaks moisture through passes (“a green strip in a desert”); he did not know how to choose forest vs grassland vs desert from it at the time. Map generation was moved to a Web Worker “to make it run acceptably fast”. — mapgen4 rainfall post; mapgen4 threads post
Azgaar’s Fantasy Map Generator (source, 2026):
- Cells from a jittered lattice (jitter 0-0.9, default 0.9); detail set by the “Points” count; the FAQ advises “set Points number to 10K” for performance, and several constants scale by (points/10000)^0.25. — Azgaar wiki: Advanced options; Azgaar wiki: Knowledge Base; precipitation-generator.ts
- Heightmaps from templates of blob primitives: Hill, Pit, Range, Trough, Strait, Add, Multiply, Smooth, Invert, each with count, height (0-100, 20 = sea level) and x/y placement ranges in percent. — Azgaar wiki: Heightmap template editor
- Temperature: sea-level temperature by latitude (tropics 16N-20S with 0.15 C/degree, linear to the poles), minus 6.5 C per km of altitude. — temperature-generator.ts
- Precipitation: “The simplest precipitation model: winds enter the map from each side and drop humidity as they pass the cells”. Wind direction per 30-degree latitude tier; humidity cap scaled by a per-5-degree latitude modifier [4,2,2,2,1,1,2,2,2,2,3,3,2,2,1,1,1,0.5] (Hadley/Ferrel/polar cells); over water the wind gains humidity; coastal cells get a burst; on land it rains max(humidity/(10·mod),1) plus uphill difference × (h/70)²; cells above height 85 block the wind entirely (all humidity drops); no flux in permafrost (< -5 C). — precipitation-generator.ts
- Rivers and lakes: depressions resolved iteratively (lakes raised to their lowest shore + 0.2; deep ones closed as endorheic lakes after 75-85% of the allowed iterations); land cells sorted high to low accumulate precipitation flux; rivers form at flux >= 30 (navigable >= 100); a lake drains through its lowest shore cell only if inflow > evaporation, evaporation from a Penman-like formula of temperature and altitude; closed lakes have no outlet. Optional downcut: cells above height 35 lower by floor(flux/mean higher-neighbor flux), at most 5. Lake subtypes: freshwater, salt, dry, sinkhole, frozen, lava. — river-generator.ts; lakes.ts; Azgaar wiki: Geographical features
- Biomes: a 5 x 26 matrix indexed by moisture band (moisture/5, 0-4) and temperature band (20 - T, clamped 0-25), with overrides: T < -5 permafrost; T >= 25, no river and moisture < 8 hot desert; wetland if moisture > 40 near the coast or > 24 at heights 25-60. Moisture = cell precipitation + river flux bonus, averaged with land neighbors. — biomes-generator.ts
- Cultures pick terrain: nomadic in deserts/grassland below height 70, highland above 50, lake cultures by lakes over 5 cells. — Azgaar wiki: Knowledge Base
WorldEngine (Mindwerks, Python + C plate library):
- Elevation from pyplatec, a C plate-tectonics simulation (defaults: 10 plates, 2 cycles, erosion_period 60, folding_ratio 0.02, aggr_overlap_rel 0.33, sea_level 0.65), then 8-octave simplex added, land centered by rolling the map to put the least land at the borders, borders faded to ocean over min(30, size/5) cells, shallow ocean floor flattened. Hills/mountains are the top 10% / 3% of heights by quantile. — worldengine/plates.py; worldengine/generation.py
- Temperature and humidity bands are quantiles (temps 0.874/0.765/0.594/0.439/0.366/0.124; humids 0.941 … 0.002) and biomes follow the Holdridge life zones; precipitation in
precipitation.pyis simplex noise shaped by a gamma curve (gamma 1.25, offset 0.2), even though the README also mentions rain shadows; rivers from flow sources with a thresholdRIVER_TH = 0.02, rivers erode, sinks without a path to sea become lakes. The README warns generation “could take a few minutes”. — WorldEngine README; simulations/precipitation.py, erosion.py
Undiscovered Worlds (Jonathan Hill, GPL):
- Global maps of 512x257, 1024x513 or 2048x1025 pixels at “roughly 16km” per pixel; regional maps at about 1 km per pixel generated on demand; “tectonic” (Earthlike continents and ranges) or “non-tectonic” terrain; mountain ranges are a separate layer from elevation; monthly temperature and precipitation per point; the author says a perfect climate sim “would require a lot more processing power and time than I have available”; “Rivers and lakes occasionally go haywire.” — Undiscovered Worlds blog, “What is Undiscovered Worlds”; GitHub
- Dec 2025: rivers and lakes “more or less” working, a simpler method for rift lakes, and hydraulic erosion only being considered. — Undiscovered Worlds blog, Dec 2025 (via search summary)
Plate tectonics done cheaply (Frozen Fractal, “Around the World” 2023): plates grown from continent Voronoi regions by simultaneous flood fill (about 15 plates for 6 continents), random velocities, boundaries classed only as converging or diverging (transforms dropped), and boundary type × crust type picks a noisy profile curve: mountains (continent-continent converge, taller with speed), rift valleys, mid-ocean ridges, island arcs, trenches with volcanic arcs. — Frozen Fractal, “Around The World, Part 2: Plate tectonics”, 13 Nov 2023
Rock strata:
- Terrafirmacraft (1.20.x data): each area gets a layer type:
ocean_floor(igneous extrusive),volcanic,land(igneous extrusive or sedimentary),uplift(sedimentary over uplifted/metamorphic, for inland mountains). Twenty rocks (granite, diorite, gabbro, shale, claystone, limestone, conglomerate, dolomite, chert, chalk, rhyolite, basalt, andesite, dacite, quartzite, slate, phyllite, schist, gneiss, marble). Stacking is a chain: pick a rock from the top layer; that rock names the layer below it; repeat until the bottom layer (gneiss, schist, diorite, granite, gabbro), which repeats forever. — TFC docs: World Presets (1.20.x) - TFC 3.x claims more predictable strata (“you’ll very likely find marble underneath a layer of surface limestone”) and ore generation rebalanced around the layers. — Modrinth, TFC 3.0.0 beta notes (via search summary)
- Vintage Story: sedimentary near the surface, metamorphic beneath, igneous at the bottom (basalt may cap anything); typically 3 layers (4 under volcanics); geologic province types (Shield, Platform, Orogen, Basin, Large Igneous Province, Extended Crust) set thickness for volcanic/sedimentary/metamorphic/igneous layers (e.g. Basin 0/50/15/255, Shield 0/0/0/255; units unlabeled); ores restricted to host rocks (e.g. galena in claystone, cassiterite in granite, coal in conglomerate). The wiki page itself has inconsistencies (20 vs 22 rocks; phyllite/slate category). — Vintage Story Wiki: Rock
Inferences
- Minecraft’s spline trick is the cheapest good-looking idea here and suits a phone: three low-frequency 2D noises (continentalness, erosion, PV) through hand-tuned piecewise-linear splines give coasts, plains and jagged peaks with per-cell cost of a few noise samples, and the same values pick biomes. But it gives no connected rivers; pair it with one priority-flood pass for drainage.
- mapgen4 is the closest model for Tycho: precompute the mesh/points (ship them as a file), keep per-seed work to noise, one priority-queue walk, one reverse accumulation and one wind sweep. Even its “erosion” is a single line (lower trunk to tributary).
- Azgaar’s latitude-banded wind table and lapse rate are small, copyable rules that give Earth-like climate belts; for a quest-sized island, one prevailing wind plus a 6.5 C/km lapse rate is enough.
- Province-driven strata (Vintage Story) or chained layer tables (TFC) cost nothing at generation time (a lookup per column); they matter only if a world shows cliffs, mines or caves.
Gaps
- No timings were found for mapgen4, Azgaar or WorldEngine on phones; Azgaar has no mobile app (“There is no mobile app” per its FAQ) and recommends 10K points for performance.
- Kniberg’s talk was not transcribed here; the wiki’s account of splines is used instead. The exact Minecraft spline control points live in the game’s data (
noise_settingsdensity functions) and were not extracted. - Undiscovered Worlds’ algorithms (erosion, climate model) are not documented in sources reached; WorldEngine’s platec internals (Viitanen’s thesis) were not read.
- No source was found describing Terrafirmacraft 1.20/1.21 plate-tectonic region generation; claims of “tectonic” TFC worldgen online are unverified.
Algorithms, with costs
Takeaway
The backbone that every serious generator converges on is cheap: priority-flood (Barnes 2014) fills depressions and gives each cell a receiver and a topological order in O(n log n); flow accumulation is then one O(n) reverse pass; and the stream-power law solved implicitly in that order (Braun & Willett 2013, as used by Cordonnier 2016) erodes river valleys with a timestep that does not depend on resolution, converging in about 200 steps. On a CPU this is about 15 ms per step at 256x256 in plain JS (measured). Droplet erosion (Beyer, Lague) is the detail pass: gullies and sediment fans. It costs a few microseconds per droplet with a precomputed brush. Thermal erosion is a few ms per iteration. Climate is one sorted sweep along the wind. Caves are either 3D-noise thresholds (Minecraft) or a few cellular-automaton passes (sub-millisecond at 50x50). Schott 2023’s local, heap-free stream-power update is built for the GPU (a compute shader). On a CPU it is slower per step and needs more steps, so it fits Tycho only if the work moves onto Tycho.gpu.
Cited Findings
Stream-power law, tectonic uplift (Cordonnier et al. 2016):
- Equation: dh/dt = u(p) - k·A(p)^m·s(p)^n (uplift minus fluvial erosion; A drainage area, s slope); “As in most geomorphological studies, we use n = 1 and m = 0.5” (m/n ≈ 0.5 is constrained by stream profiles). — Cordonnier, Braun, Cani, Benes, Galin, Peytavie, Guérin, “Large Scale Terrain Generation from Tectonic Uplift and Fluvial Erosion”, CGF 35(2), Eurographics 2016
- Method: Poisson-disc nodes + Delaunay graph; the user paints an uplift map; each step builds stream trees (each node’s receiver = steepest downhill neighbor), computes drainage area by summing Voronoi cell areas from leaves to roots, and solves the implicit update h_i(t+δt) = (h_i(t) + δt·(u_i + k·A_i^m·h_j(t+δt)/‖p_i-p_j‖)) / (1 + k·A_i^m·δt/‖p_i-p_j‖), which needs the receiver j solved first, so nodes are parsed in stream-tree order. Lakes (internal roots) are connected by a lake graph in O(N + M log M) (M lakes ≪ N), instead of Braun & Willett’s optional O(N√N) step. — Cordonnier et al. 2016
- Parameters: 50x50 km terrain, max uplift U = 5.0e-4 m/yr, k = 5.61e-7 /yr (for peaks ~2000 m), δt = 2.5e5 years. Timing on a 3 GHz Core i7 (single thread, “did not fully optimize”): 10,000 samples 0.031 s per step, 6.4 s to converge; 40,000: 0.177 s / 35.4 s; 90,000: 0.401 s / 78 s; 160,000: 1.273 s / 252 s. “Convergence is obtained after around 200 time steps”, and thanks to the implicit solve the step count “does not depend on the resolution”. — Cordonnier et al. 2016, §6 and Table 1
Stream power for authoring (Schott, Paris, Fournier, Guérin, Galin, ACM TOG 2023):
- The user paints uplift (local elevation speed); fluvial erosion is “continuously simulated through the Stream Power Equation”; the paper relies on “a fast yet accurate approximation of drainage area and flow routing” for interactive, incremental authoring, plus copy-paste, warping for folds and faults, and point/curve elevation constraints. — Schott et al. project page; H-Schott/StreamPowerErosion README
- The released code is an OpenGL 4.5 compute shader (8x8 work groups, ping-pong buffers). Per cell per step: drainage area = cell area + Σ neighbor area × (neighbor’s slope share toward this cell)^p_flow (multiple-flow routing, read from the previous step, so area propagates one cell per step); erosion = k·noise·A^0.8·s^2.0 along the steepest receiver, plus hillslope diffusion k_h·∇²h (and optional debris term k_d·s); new height clamped to be no lower than the receiver; then + dt·uplift. Defaults: uplift 0.03, k 0.0005, k_d 10, k_h 200, p_sa (m) 0.8, p_sl (n) 2.0, dt 50, p_flow 1. Border cells fixed at 0. — StreamPowerErosion, data/shaders/spe_shader.glsl
Priority-flood (Barnes, Lehman, Mulla 2014):
- Depression filling that is “optimal for both integer and floating-point data, working in O(n) and O(n lg n) time, respectively”; an improved variant (plain FIFO queue inside depressions, priority queue elsewhere) is O(m lg m) with m ≤ n and ran “up to 37% faster”; a parallel version of an older algorithm needed six processors to match it on one; it “can also be adapted to label watersheds and determine flow directions” (with epsilon increments or by carving). — Barnes et al., “Priority-Flood”, Computers & Geosciences 62 (2014) 117-127, arXiv:1511.04463
- mapgen4 uses exactly this shape on its triangles (queue seeded with ocean, walk uphill by elevation, record downslope and order). O’Leary instead used iterative Planchon-Darboux filling (repeat full sweeps until nothing changes, epsilon 1e-5), and Azgaar iterates lake-raising passes, which is cheaper to write but has no tight bound. — mapgen4 map.ts; mewo2/terrain; Azgaar river-generator.ts
Droplet (particle) hydraulic erosion (Beyer 2015; Lague 2019):
- Beyer’s TU München bachelor’s thesis (15 Nov 2015): each drop carries direction, speed, water, sediment; gradient and height by bilinear interpolation; capacity = max(-Δh, minSlope)·speed·water·p_capacity; erode over a radius when under capacity, deposit bilinearly at the current point when over (no radius, so pits fill); the change map can be blurred and blended (factor b). Timing table (Core i5-3470, 3.2 GHz, parameters inertia 0.3, capacity 8, deposition 0.2, erosion 0.7, evaporation 0.02, minSlope 0.01, gravity 10, radius 4, maxPath 64): 256x256 with 10,000 drops 2.27 s; 100,000 drops 32 s; time barely depends on map size (2048x2048, 100k drops 43.8 s) but grows with radius² (10k drops at radius 1/2/4/6/8: 0.48/0.83/2.27/5.51/9.69 s). About 16,500 of 100,000 drops ran off a 256x256 map. — Beyer, “Implementation of a method for hydraulic erosion”, TUM 2015
- Sebastian Lague’s Unity port defaults: map 255x255, 50,000 droplets (TerrainGenerator), brush radius 3, inertia 0.05, sedimentCapacityFactor 4 (3 in the generator), minSedimentCapacity 0.01, erodeSpeed 0.3, depositSpeed 0.3, evaporateSpeed 0.01, gravity 4, maxDropletLifetime 30, initial water 1 and speed 1; brush weights precomputed per cell; a compute-shader version also ships. — SebLague/Hydraulic-Erosion, Erosion.cs and TerrainGenerator.cs
- Erodr (C, Beyer’s algorithm) defaults: 70,000 particles, ttl 32, radius 2, gravity 1, inertia 0.3, capacity 8, evaporation 0.02, erosion 0.7, deposition 0.3, min slope 0.0001. — henrikglass/erodr README
Thermal erosion and talus:
- Thermal erosion moves material from a cell to lower neighbors while the height difference exceeds the talus angle (the steepest stable slope for that material), evening out sharp changes; Olsen (2004) described speed optimizations for real-time game maps. — Smelik et al., “A Survey of Procedural Methods for Terrain Modelling”, TU Delft 2009
- Folklore flag: the oft-quoted Olsen constants (talus T = 4/N for an N-cell map, move half the excess, ~50 iterations) are repeated widely in blog posts but could not be checked against the paper for these notes; the bench below uses that form with a hand-picked T.
- Schott’s shader folds hillslope processes into the stream-power step as Laplacian diffusion (k_h) plus a debris-slope term (k_d), a cheaper continuous stand-in for talus moves. — spe_shader.glsl
Lakes and endorheic basins:
- With priority-flood, lake depth is just filled minus original height; the fill surface is the lake level. Cordonnier treats lakes as internal root nodes and routes their overflow through the lowest saddle. — Cordonnier et al. 2016 §4.3
- Endorheic (closed) lakes by water balance: Azgaar drains a lake through its lowest shore cell only if inflow (shoreline precipitation + rivers) exceeds evaporation (a Penman-based estimate from temperature and altitude); deep depressions are marked closed; subtypes include salt and dry lakes. DF “grows” lakes at several points along rivers and bulges some out. — Azgaar lakes.ts; river-generator.ts; Game Developer 2008 interview
Coastlines and continental shelves:
- Minecraft’s continentalness bands act as a shelf profile: deep ocean, ocean, coast, near-, mid-, far-inland, each with spline-driven height offsets. — Minecraft Wiki: World generation
- WorldEngine flattens the ocean floor (values above and below a mid-depth are compressed by a factor of 5 toward it) and scales sea depth by distance to land (factors 0, 0.3, 0.5, 0.7, 0.9 for 1-5 cells out, then anti-aliased), which makes a shelf; oceans are flood-filled from the map edges so inland low spots are not sea. — worldengine/generation.py
- Island masks: Red Blob gives d = 1-(1-nx²)(1-ny²) (square bump) or d = min(1,(nx²+ny²)/√2), and e = lerp(e, 1-d, mix); mapgen4 adds coastline noise weighted by (1-e⁴) so it acts mostly near the shore. — Red Blob, “Making maps with noise”; mapgen4 map.ts
Wind, moisture and rain shadow: (see mapgen4 and Azgaar above) one sorted pass along the wind: humidity carried from upwind neighbors, gained over water, capped by (1 - elevation) or lost on climbing slopes (orographic rain), so the lee side is dry. DF applies the same correction once after final elevations and only (per a forum report) where drainage >= 50. — mapgen4 map.ts; Azgaar precipitation-generator.ts; DF wiki: Advanced world generation
Biomes: Whittaker and Köppen:
- Whittaker-style lookup on elevation × moisture (Red Blob’s example): e < 0.1 ocean; < 0.12 beach; e > 0.8: scorched/bare/tundra/snow at m < 0.1/0.2/0.5/else; e > 0.6: temperate desert/shrubland/taiga at m < 0.33/0.66/else; e > 0.3: temperate desert/grassland/temperate deciduous forest/temperate rain forest at m < 0.16/0.5/0.83/else; else subtropical desert/grassland/tropical seasonal forest/tropical rain forest at m < 0.16/0.33/0.66/else. Patel: “every project I’ve worked on I’ve had to change” these numbers. — Red Blob, “Making maps with noise”
- Azgaar’s 5 x 26 moisture × temperature matrix and WorldEngine’s Holdridge life zones are the same idea with temperature as an axis. — Azgaar biomes-generator.ts; WorldEngine README
- Köppen-Geiger (Peel, Finlayson, McMahon 2007) needs monthly temperature and precipitation: Pthreshold = 2·MAT if 70% of precipitation falls in winter, 2·MAT+28 if 70% in summer, else 2·MAT+14; arid B if MAP < 10·Pthreshold, desert W if MAP < 5·Pthreshold, steppe S otherwise. A PMC reproduction of the table gives steppe as ≥ 10·Pthreshold, conflicting with the Melbourne copy; check the HESS original. — Peel’s Köppen page, University of Melbourne; Peel et al., HESS 11:1633-1644 (2007); PMC table reproduction
Caves:
- Noise caves (Minecraft 1.18): cheese = threshold of a 3D noise, spaghetti/noodle = narrow band around a noise’s zero crossing, pillars inside cheese; evaluated per block alongside terrain density, no global pass; aquifer cells give underground lakes at stepped levels. — Minecraft Wiki: Cave
- Cellular automata (Johnson, Yannakakis, Togelius, FDG 2010): 50x50 grid, initial rock ratio r = 0.5, n = 4 iterations, Moore neighborhood M = 1, rock if neighborhood value >= T = 5 (the paper’s rule wording: rock if greater than T); CA cost ∝ (2M+1)²; 0.41 ms per 50x50 map in C#/XNA; a 3x3 grid of interconnected rooms (2 extra iterations on joined borders) in 349 ms. — Johnson et al., “Cellular automata for real-time generation of infinite cave levels”, FDG 2010
- Karst: Paris, Guérin, Peytavie, Collon, Galin (CGF 2021) build karst network skeletons by a gridless anisotropic shortest path from inlets to outlets, steered by faults, inception horizons, fractures and permeability contrasts, then make conduits as a signed-distance construction tree with blending and warping. A related tool (KarstNSim) reports sub-vertical conduits in the unsaturated zone and sub-horizontal ones below the water table. — Paris et al., “Synthesizing Geologically Coherent Cave Networks”, CGF 40(7) 2021 (water-table detail via search summary)
- DF caverns: up to 3 cavern layers, each with openness 0-100 (open expanse vs passages) and passage density 0-100 (maze of passages), water 0-100, plus magma sea and bottom layer. — DF wiki: Advanced world generation
Local measurements, plain JS (Node v26, Intel i9-9880H 2.3 GHz laptop, one thread), from world_generation_bench.js:
- 256² / 512²: priority-flood 16 / 56 ms; accumulation 1.2 / 2.7 ms; stream power (Braun-Willett implicit, flood+accumulate+solve) 31 / 118 ms for the first step, 289 / 1015 ms for 20 steps (about 14 / 50 ms per step); droplets 0.5 per cell 128 / 445 ms (about 4 µs per droplet, radius 3, life 30); thermal 50 iterations 86 / 318 ms; wind sweep 1.4 / 2.4 ms; CA caves 5 passes 23 / 54 ms; fBm 6 octaves value noise 10 / 29 ms; diamond-square 3.5 / 9 ms.
- Schott-style local stream power (port of the GLSL kernel to a CPU loop): 39 / 129 ms for one step, 2.8 / 10.5 s for 100 steps. That is about 2x the implicit solver’s cost per step, and it needs at least as many steps as the grid is wide before drainage area reaches the outlets. — local benchmark (no external source)
Inferences
- Beyer’s own timings (2.27 s for 10k drops at radius 4) are about two orders of magnitude slower than the JS bench (33k drops in 128 ms at radius 3). The gap is implementation (precomputed brush offsets and weights, typed arrays, shorter life) more than language, so droplet cost is controllable. Budget by droplets × life × brush cells.
- For a CPU-only phone build, the order of work should be: noise or spline base, then priority-flood, then 10-40 implicit stream-power steps (at δt large enough to converge fast), then 0.25-0.5 droplets per cell for gullies, then thermal or Laplacian smoothing for talus, then wind sweep, then biome lookup. Cordonnier’s 200 steps to steady state is about 3 s at 256² in JS, too slow for a phone unless started from a terrain already near equilibrium (noise shaped like ridges) or run on a coarse grid (128²: roughly a quarter of the cost) and upsampled.
- Schott’s kernel is the one that maps onto a GPU fragment-shader ping-pong (each cell reads only its 8 neighbors). If
Tycho.gpuoffers render-to-texture with float targets, thousands of steps per second at 256² are plausible. That is an inference, not measured. - Karst and lava tubes have no cheap game-ready algorithm in the sources. For a 2D/isometric pixel game, CA caves or noise-band “spaghetti” in a 2D slice, gated by rock type (limestone strata for karst, basalt near volcanism for tubes), is the realistic budget answer.
Gaps
- Schott 2023’s paper timings (resolution, ms per step on GPU) could not be retrieved (HAL blocked the fetch; ACM paywalled); only the shader defaults are cited.
- Braun & Willett 2013 (the O(n) implicit solver) was not fetched directly; its use and complexity are cited through Cordonnier et al.
- Olsen 2004’s exact thermal-erosion constants are unverified (see folklore flag).
- No game or paper source was found for procedural lava tubes suited to games; the PLUME framework (arXiv 2508.20926) targets planetary shelters, not games.
- No measurements on actual phones were made; the bench ran on a 2019 laptop under Node.
What fits a 256x256 or 512x512 grid on a phone in 1-3 s of JS, and what must be precomputed
Takeaway
On a 256x256 grid, everything except long erosion runs costs tens of milliseconds in plain JS: base noise, priority-flood, flow accumulation, rivers, lakes, wind and rain shadow, biomes, CA caves. The whole pipeline fits well under 1 s on a laptop, including about 20 implicit stream-power steps and a light droplet pass, which leaves room for a phone’s slower CPU (unmeasured here). At 512x512 the same pipeline is about 4x the cost: 1-3 s with erosion trimmed. Things to precompute and ship rather than run per seed: a Poisson point set or mesh, as mapgen4 does; spline and biome tables; and, for any “realistic” steady-state mountain range, either a coarse-grid stream-power run (128²) upsampled or a stock of eroded tiles. Full stream-power equilibrium (about 200 steps) is the one stage that does not fit at 512² on a phone.
Cited Findings
- Reference grid sizes in shipped tools: DF world maps 17 to 257 squares a side (257² = 66,049); Lague’s erosion demo 255²; mapgen4 is tuned to look best around 25k cells and loads precomputed points (
build/points-5.5.data) instead of sampling them; Azgaar recommends 10K points for performance and has no mobile app; O’Leary uses 16,384 points; Johnson et al.’s CA caves are 50x50 chunks. — DF wiki: World generation; SebLague/Hydraulic-Erosion; mapgen4 README; mapgen4 mesh.ts; Azgaar Knowledge Base; mewo2/terrain; Johnson et al. 2010 - Published costs: Cordonnier’s stream power, 10,000 nodes, 0.031 s per step and 6.4 s to converge (about 200 steps) on a 3 GHz i7; 40,000 nodes 0.177 s per step. Beyer’s droplets at 256², 10,000 drops 2.27 s (radius 4, path 64) on a 3.2 GHz i5. Johnson’s CA, 0.41 ms per 50x50 map (C#). DF says world-map generation is “generally much faster than history generation”. — Cordonnier et al. 2016; Beyer 2015; Johnson et al. 2010; DF wiki: World generation
- mapgen4 moved generation into a Web Worker “to make it run acceptably fast” and keeps the map in the worker, transferring geometry buffers to the main thread. — Patel, “mapgen4: threads”, 5 Sep 2018
- Local measurement (this folder’s
world_generation_bench.js, Node v26.8.1, Intel i9-9880H 2.3 GHz laptop, one thread, Float32Array/Int32Array, 8-neighbor grids). Times in ms, 256x256 / 512x512:- fBm value noise, 6 octaves: 10.2 / 28.6
- diamond-square midpoint displacement: 3.5 / 9.0
- priority-flood with epsilon fill, binary heap, also records receivers (flow directions) and flood order: 15.8 / 55.9
- flow accumulation in reverse flood order: 1.2 / 2.7
- D8 steepest receivers + sort-by-height accumulation (the “textbook” way): 25.1 / 99.8
- stream-power law, implicit Braun-Willett solve, 1 iteration (flood + accumulate + solve): 31.3 / 118.4
- stream-power law, 20 iterations: 289 / 1015
- droplet erosion, Beyer/Lague defaults, radius-3 brush, 0.5 droplets per cell (33k / 131k droplets): 128 / 445
- thermal erosion, 50 iterations, 4-neighbor talus: 86 / 318
- one west-to-east wind moisture sweep with an orographic cap (rain shadow): 1.4 / 2.4
- cellular-automata caves (45% fill, 5 passes of the 4-5 rule): 23 / 54
- Schott-style local (GPU-shaped) stream power on the CPU: 39 / 129 ms per step
— local benchmark,
world_generation_bench.js(no external source)
Inferences
- A budget for 256x256, from the laptop numbers (multiply by an unmeasured phone factor, plausibly 2-4x for a mid-range Android; verify on a device): base (Minecraft-style continentalness/erosion/PV noises through splines, or fBm plus an island mask) about 30 ms; priority-flood plus accumulation about 20 ms; 20 implicit stream-power steps about 300 ms; 0.25 droplets per cell about 60 ms; 20 thermal iterations about 35 ms; wind sweep plus temperature (latitude plus lapse rate) plus biome lookup under 10 ms; final priority-flood for lakes and rivers about 20 ms. That totals about 0.5 s on the laptop, roughly 1-2 s on a phone, in a Web Worker so the frame loop never stalls.
- 512x512 is about 4x per stage: about 2 s on the laptop with the same step counts. On a phone, either cut stream power to about 5 steps (or run it at 256² and upsample, then add droplets at 512²) or precompute.
- Precompute and ship: Poisson/mesh points (mapgen4’s choice), splines and biome tables, rock-strata tables (TFC/VS style), and any “fully eroded” mountain set. Compute per seed: noise, flood, rivers, lakes, climate, biomes, caves. Never per frame: erosion. For Tycho, a GPU path (Schott’s kernel as a fragment-shader ping-pong on
Tycho.gpu) is the only way to make full steady-state erosion interactive on a phone. - DF-style reject-and-reroll belongs on a coarse pass: generate at 64² or 128² (a few ms), test land fraction, a river reaching the sea, a mountain count, the biomes the quest needs, then run the expensive stages once at full size. Because Elsewhere requires
ctx.rng-seeded determinism, the reroll must derive the next seed from the current one (seed, seed+1, …) so a saved world always regenerates identically. - Typed arrays (Float32Array heights, Int32Array receivers and order), a hand-rolled binary heap, and precomputed brush offsets are what make these numbers possible. Array-of-object or
Array.prototype.sort-heavy code (as in O’Leary’s D3 version) is several times slower: in the bench, the sort-based D8 accumulation took 25 ms against 1.2 ms for the flood-order pass at 256².
Gaps
- No phone measurements: the phone-versus-laptop factor is a guess pending a run of
world_generation_bench.js(or a browser port of it) on target devices. No reliable published mid-range-phone JS benchmark was found; Speedometer comparisons online are flagship-only and vendor-sourced. - Web Worker transfer and startup costs on phones were not measured.
- Whether
Tycho.gpusupports float render targets (needed for a GPU erosion ping-pong) is outside these notes; see the Tycho-focused notes.