Home Layered Terrain Generation

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 stone family) 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

flowchart TB Seed[Existing terrain\nseed_distance_field] --> Elev Elev[Layer 0\nElevation fBm + ridge] --> Moist[Layer 1\nMoisture fBm] Elev --> ElevSmooth[Elevation blur\nN passes] ElevSmooth --> Biome[Layer 2\nBiome lookup\nelevation x moisture] Moist --> Biome Biome --> BiomeSmooth[Layer 2b\nMajority-filter smoothing] BiomeSmooth --> Fill[Layer 3\nBase family/kind fill\nelevation-banded] ElevSmooth --> Rivers[Layer 4a\nRivers: steepest descent] ElevSmooth --> Lakes[Layer 4a\nLakes: basin fill] Fill --> Paths[Layer 4b\nPaths: A* between\nroads/POIs] Rivers --> Heal[Layer 5\nheal_adjacency\nunchanged] Lakes --> Heal Paths --> Heal Fill --> Heal Heal --> Coalesce[Layer 6\ncover_cells_with_rects\nunchanged]

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) with ridge_weight a 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_bias knobs 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 path family, narrow width, before heal_adjacency runs (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)

  1. Elevation field + multi-pass smoothing — add Layer 0, wire default_elevation_for_family to 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.
  2. 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.
  3. 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.
  4. Elevation-banded base fill (Layer 3) — kind selection within biome, replacing the old cross-family weighted draw.
  5. Basin-fill lakes + steepest-descent rivers (Layer 4a) — retire the old percentile-proxy water logic once this is validated to hit the same water_amount targets.
  6. A path carving (Layer 4b)* — net-new, ship behind generate_paths: false default so it's opt-in until validated.
  7. Re-tune heal_adjacency pass count — with layering doing more upfront legality work, confirm 4 passes is still enough (or can drop) and update assets/world/terrain-adjacency.yaml rules 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: true produces a connected path between two anchor points that avoids water and steep slope, carved as path family, legal under existing adjacency rules.
  • heal_adjacency healed-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_bias knobs still hit their target coverage percentage under the new basin-fill lake logic.
  • No change to segment YAML schema — output is still coalesced terrain_zones AABBs, so 38's downstream consumers (sampling, adjacency, transitions) need zero changes.