Layered Terrain Generation (Elevation-First Generate)
Replace the flat single-noise-draw core of admin Generate with a layered pipeline — elevation → climate → biome → base fill → carved features (rivers/paths) → smoothing — so generated land reads as intentional (mountain ranges, valleys, rivers that flow downhill, paths that connect places) instead of statistically-plausible speckle.
Related: 38 — Biomes, adjacency & terrain transitions (catalogs/adjacency/heal this plan builds on top of, unchanged), 47 — Terrain forge v2 (tile texture painting — unrelated system, not touched here).
1. Problem
generate_terrain() (crates/sim/src/terrain_gen.rs:230) already has real machinery — domain-warped fBm, a box blur, percentile thresholds, adjacency healing — but every terrain family (grass, dirt, stone, wetland) is drawn from one flat noise field partitioned by weight (domain_family(), line 1102). There is no real heightmap: default_elevation_for_family() (line 1244) is binary — water/wetland = -0.5, everything else = 0.0. Consequences:
- "Mountains" (the
stonefamily) are isolated patches with no elevation gradient, no ridge structure, no foothills — they can appear directly beside grass with nothing between them. - Lakes/rivers are thresholded off an elevation-proxy channel that isn't the same field terrain families are drawn from, so water and land shape are statistically unrelated to each other.
- Paths/roads are never generated — Generate only ever treats them as frozen pre-existing context. The "grass, then paths, then mountains" layered narrative can't emerge because there's no path layer at all.
- Only the water-membership channel gets blurred; the family-selection field itself is warped but not smoothed, so family borders are noise-wobbly rather than clean gradients.
2. Locked principles
| Decision | Rule |
|---|---|
| Output format unchanged | Still coalesced AABB terrain_zones (38 §10) — this plan changes the algorithm inside terrain_gen.rs, not the segment schema |
| Continue-only, still honored | New pipeline still never overwrites occupied/locked cells; existing seed-continuation logic (seed_distance_field) stays as the layer-0 boundary condition |
| Adjacency/heal unchanged | heal_adjacency() and assets/world/terrain-adjacency.yaml stay the final legality pass — layering reduces how often it needs to fire, it doesn't replace it |
| Elevation is real, not a proxy | One authoritative elevation field per generation, used by both family classification and water/river carving — no more disconnected proxy channel |
| Order matters | Elevation → moisture → biome → base fill → rivers/lakes → paths → smoothing → heal → coalesce. Each layer only reads layers above it, never below |
| Incremental delivery | Each layer is an independently shippable change against the same starter-plains segment; no big-bang rewrite |
| No infinite/streaming worldgen | Same non-goal as 38 §10.3 — this is still an authoring-time tool, not runtime chunk generation |
3. Pipeline
Layer 0 — Elevation heightmap
New field, larger spatial scale than today's family noise (the backbone everything else reads from):
- Base: existing fBm machinery (
fbm_warped_field, line 1541) reused at a lower frequency (fewer, bigger features per selection). - Ridge component blended in at low weight:
ridge(n) = 1.0 - (2.0 * n - 1.0).abs(), so elevation forms linear ranges instead of Perlin-blob peaks.elevation = lerp(fbm, ridge, ridge_weight)withridge_weighta new Generate knob (default ~0.35). - Seeded/anchored at selection edges from neighbor elevation where
default_elevation_for_family()already assigned a value, so a Generate box picks up the slope of whatever's next to it instead of starting flat.
Elevation smoothing
Run box_blur3 (already exists, line 1609) 2–3 passes over the elevation field itself, not just the water proxy. This is the direct fix for "no gradients" — cheap, already-implemented primitive, just applied to the field that actually matters and applied more than once.
Layer 1 — Moisture/climate field
A second fBm field, independent seed offset from elevation (same fbm_warped_field helper, different seed salt), low frequency. Not blended with elevation — kept orthogonal so biome lookup is a genuine 2D classification, not elevation in disguise.
Layer 2 — Biome lookup (elevation × moisture)
Replaces the flat weighted-bucket draw in domain_family() for the macro decision. A small lookup table (Whittaker-style), e.g.:
| Elevation | Moisture low | Moisture mid | Moisture high |
|---|---|---|---|
| Low | arid |
grassland |
wetland |
| Mid | bare_soil |
grassland |
grassland |
| High | stone |
stone |
stone |
Existing biome_id override / preferred_families from assets/world/biomes.yaml still applies as a bias on top of the table (a mountainous biome nudges the high-elevation band wider, etc.) rather than being bypassed.
Layer 2b — Majority-filter smoothing (the direct "less random" fix)
Before base fill, run an N×N (start with 3×3, tune to 5×5) mode filter over the biome grid: replace each cell with the most common biome among its neighbors. This is the same technique Minecraft's biome smoothing pass uses to kill single-cell speckle before terrain placement. Cheap, and probably the single highest visual-impact change in this plan for the "just randomness" complaint.
Layer 3 — Base family/kind fill
Per cell: look up the (now-smoothed) biome, then pick the specific kind (e.g. stone biome → hill vs rock) using elevation banding within that biome rather than a fresh noise draw — this keeps today's resolve_land_weights weighting logic but scopes it within a biome instead of across all families globally, and gates stone-family kinds by elevation band so hills only appear at higher elevation than rock's lower edge, etc.
Layer 4a — Rivers and lakes from real elevation
- Lakes: flood-fill local minima of the smoothed elevation field (basin detection) instead of percentile-thresholding the old disconnected proxy channel. Coverage still targets the existing
water_amount/lake_biasknobs by picking basins in ascending-depth order until target coverage is hit. - Rivers: from a small number of high-elevation seed cells, walk steepest-descent (move to the lowest neighbor each step) until reaching a lake, existing water, or the selection edge. Carve a 1–2 cell wide path, tapering width with accumulated flow (cells downstream of a river confluence get slightly wider). Existing ridge-noise river channel is retired in favor of this — it's the same conceptual feature done correctly (river ⟂ elevation instead of river ⟂ unrelated noise).
Layer 4b — Paths (new — does not exist today)
Optional layer, off by default behind a new generate_paths: bool knob:
- Collect anchor points: existing road/trail cells at the selection boundary (continuation seeds) plus any structures/POIs inside the selection (if present in the segment).
- Connect anchors pairwise (or via MST to avoid O(n²) redundant paths) with A* over a cost field: flat + dry terrain cheap, water/steep-slope/occupied cells expensive-or-blocked.
- Carve the resulting path as
pathfamily, narrow width, beforeheal_adjacencyruns (so path/family seams get the same legality pass as everything else). - This is the concrete mechanism for "paths get put down" as a deliberate layer rather than never existing.
Layer 5 / 6 — Heal + coalesce (unchanged)
heal_adjacency() (line 1251) and cover_cells_with_rects() (line 1430) are reused as-is. Layering should make heal's job smaller (fewer illegal seams reach it), which is itself a good regression signal — track healed-cell count before/after per layer as a sanity metric.
4. New/changed knobs
Extends TerrainGenerateApiRequest (tools/content-admin/src/api/terrain_generate.rs:25) and CLI (tools/flatland-admin/src/map_cmd.rs:46):
| Knob | Default | Purpose |
|---|---|---|
ridge_weight |
0.35 |
Blend of ridge-noise vs. plain fBm in elevation (0 = rolling hills, 1 = sharp ranges) |
elevation_smooth_passes |
2 |
Box-blur passes over the elevation field |
biome_smooth_passes |
1 |
Majority-filter passes over the biome grid |
biome_smooth_window |
3 |
N×N window for the majority filter |
generate_paths |
false |
Enable Layer 4b path carving between anchors |
path_cost_slope_penalty |
2.0 |
How strongly steep elevation discourages A* path routing |
Existing knobs (seed, family_weights, water_amount, lake_bias, edge_blend_m, blend_overwrite_m, biome_id, llm_refine) are kept; family_weights shifts meaning slightly — it now biases within-biome kind selection (Layer 3) rather than the global family draw, since biome assignment itself comes from the elevation × moisture table.
No server-settings.yaml changes needed — these are per-request Generate knobs, same pattern as today's (crates/settings/src/lib.rs:1130 LlmSettings stays untouched).
5. Build order (incremental, each step independently testable against starter-plains)
- Elevation field + multi-pass smoothing — add Layer 0, wire
default_elevation_for_familyto read real elevation instead of the binary water/land split. No family-selection change yet; ships as "elevation now varies realistically" with a visualization/debug dump for sanity-checking before it drives anything else. - Biome lookup replacing
domain_family()— elevation × moisture table (Layers 1–2), still no smoothing pass yet. This is the big structural change; validate stone no longer appears isolated in flat grassland. - Majority-filter biome smoothing (Layer 2b) — cheapest change, biggest speckle reduction; ship on its own once Layer 2 lands so its effect is measurable in isolation.
- Elevation-banded base fill (Layer 3) — kind selection within biome, replacing the old cross-family weighted draw.
- Basin-fill lakes + steepest-descent rivers (Layer 4a) — retire the old percentile-proxy water logic once this is validated to hit the same
water_amounttargets. - A path carving (Layer 4b)* — net-new, ship behind
generate_paths: falsedefault so it's opt-in until validated. - Re-tune
heal_adjacencypass count — with layering doing more upfront legality work, confirm 4 passes is still enough (or can drop) and updateassets/world/terrain-adjacency.yamlrules if new biome-adjacent-family combinations need explicit allow/forbid entries.
Each step touches only crates/sim/src/terrain_gen.rs (plus the small knob additions in step 6) and can be validated by running flatland-admin map generate against the same test selection before/after and diffing zone counts, healed-cell counts, and a visual render.
6. Exit criteria
- Elevation field is real and continuous (no binary water/land split); visualized/dumped elevation shows smooth gradients, not noise speckle.
- Stone/hill terrain only appears at higher elevation bands and clusters into ranges (ridge structure visible), never isolated single cells in flat low-elevation grassland.
- Rivers flow from higher to lower elevation and terminate in a lake, existing water, or the selection edge — never crossing uphill.
- Biome grid shows no single-cell "salt and pepper" speckle after the majority-filter pass.
generate_paths: trueproduces a connected path between two anchor points that avoids water and steep slope, carved aspathfamily, legal under existing adjacency rules.heal_adjacencyhealed-cell count on a standard test selection is lower after layering than before (fewer illegal seams reaching the final repair pass).- Existing
water_amount/lake_biasknobs still hit their target coverage percentage under the new basin-fill lake logic. - No change to segment YAML schema — output is still coalesced
terrain_zonesAABBs, so 38's downstream consumers (sampling, adjacency, transitions) need zero changes.