Biomes, Adjacency & Terrain Transitions
How Flatland defines, validates, generates, and mutates map surfaces. Terrain remains AABB terrain_zones plus optional runtime cell deltas — not a full on-disk grid.
Related: 37 — Economic map zones, 21 — World content schemas, 23 — Harvest, 15 — Active stats (vitals drains), 06 — Visibility.
1. Summary
| Concern | Mechanism |
|---|---|
| Surface | Authored terrain_zones (kind, tile_id, elevation) — shipped |
| Climate | biome_zones + biome catalog |
| Neighbors | Adjacency rules on kind / terrain family (not every art sheet) |
| Change over time | Transition catalog: natural / cultivate / reclaim edges |
| Weather | Per–biome-zone state enum; multiplies transition and growth rates |
| Procgen | Authoring-time greedy fill + heal in web Map (+ CLI) |
| Live edits | HashMap terrain deltas; hybrid hot/cold heartbeat |
Economic overlays (tax / property / growth) stay on 37. Growth may gate cultivate and constrain generate masks via allowed_kinds / allowed_biomes.
2. Locked principles
| Decision | Rule |
|---|---|
| Layers separate | Terrain ≠ biome ≠ tax/property/growth |
| Adjacency key | Sim kind / family; sheet forbids only when necessary |
| Cultivate gate | Requires an existing growth zone (37); does not invent parcels |
| Procgen when | Authoring time (Map layout / CLI → YAML). No streaming infinite world here |
| Procgen how | Greedy neighbor fill + heal seams (not full WFC) |
| Delta store | HashMap<(cx,cy), TerrainDelta>; optional bloom filter later |
| Weather role | Multiplier on transition speed and growth respawn — not the primary event trigger |
| Heartbeat | Hot zones simulate continuously; cold zones catch up on entry |
3. Layer stack
At (x,y) the worker merges: authored terrain zone → runtime delta (if any) → biome → econ overlays (37).
4. Terrain family
Art sheets (terrain.grass, terrain.grass_2, terrain.dirt_3, …) map to a family used by adjacency and transitions:
| Family (examples) | Typical kinds | Notes |
|---|---|---|
grassland |
grass |
Default plains |
bare_soil |
future dirt / tilled staging |
Degrade / fallow |
tilled |
tilled (new kind when movement differs) |
Cultivate target |
wetland |
bog, shallow_water |
|
water |
deep_water, shallow_water |
|
arid |
desert (new kind when needed) |
|
stone |
rock, hill |
|
path |
trail, road |
Always allow beside most families |
Catalog: extend assets/world/terrain-kinds.yaml with family: per kind, or a small terrain-families.yaml. Gfx tile_id stays presentation; rules bind kind/family.
Extend TerrainKind in sim only when stamina/speed must differ (tilled, desert, …).
5. Biome catalog & biome zones
5.1 Catalog (assets/world/biomes.yaml)
biomes:
- id: temperate_plains
label: Temperate plains
map_color: "#5a8f4a" # Map layout biome-zone tint
preferred_families: [grassland, path, bare_soil, tilled]
weather_weights: # biases for weather picker when online
clear: 0.5
rain: 0.3
drought: 0.15
storm: 0.05
drain_tags: [] # optional vitals DrainModifier tags
5.2 Segment biome_zones
Same rect / z_order pattern as terrain:
biome_zones:
- id: starter-temperate
biome_id: temperate_plains
z_order: 0
rects:
- { x0: 0, y0: 0, x1: 256, y1: 256 }
biome_at(x,y) → catalog entry. Used for adjacency affinity, weather home, drain hints, and growth allowed_biomes checks.
6. Adjacency
6.1 Rules (assets/world/terrain-adjacency.yaml)
rules:
- family: arid
forbids: [wetland, water]
allows: [arid, bare_soil, stone, path]
- family: grassland
forbids: [arid] # no desert glued to lush grass
allows: [grassland, bare_soil, tilled, path, wetland]
Optional: in_biomes: [temperate_plains] to scope a rule. Optional rare tile_id overrides.
6.2 Editor behavior
| Mode | Forbidden neighbor |
|---|---|
| Paint (web Map) | Soft warn; allow override for hand repair |
| Generate / Fill | Hard reject; backtrack or pick another family |
| Validate | Report edge list for designers |
Native editor may later mirror validate; web Map layout is required for generate/validate UX.
7. Transitions (degrade / cultivate / reclaim)
7.1 Catalog (assets/world/terrain-transitions.yaml)
weather_states: [clear, rain, drought, storm]
transitions:
- id: grass_to_dirt
from_family: grassland
to_family: bare_soil
to_kind: dirt # when kind exists; else tile_id
agent: natural
base_ticks: 18000 # progress units at mult 1.0
weather_mult:
clear: 1.0
rain: 0.5
drought: 3.0
storm: 0.8
require_growth_zone: false
- id: cultivate_grass
from_family: grassland
to_family: tilled
to_kind: tilled
agent: cultivate
base_ticks: 90 # channel length for intent
weather_mult: # all 1.0 until weather ships is fine
clear: 1.0
rain: 1.0
drought: 1.0
storm: 1.0
require_growth_zone: true
require_allowed_kind: true
- id: fallow_to_grass
from_family: tilled
to_family: grassland
agent: reclaim
base_ticks: 36000
weather_mult:
clear: 1.0
rain: 1.2
drought: 0.7
storm: 1.0
require_growth_zone: false
7.2 Weather as multiplier (locked)
progress += dt_ticks * catalog.weather_mult[weather_state]
# complete when progress >= base_ticks
- Natural edges always exist; drought makes
grass → dirtfaster — it does not fire a one-shot map wipe. - Until weather exists, every mult is 1.0 (no schema change when weather lands).
- Growth respawn uses the same idea:
effective_respawn = base / fertility * weather_mult(37).
7.3 Cultivate
- Intent
Cultivate(tool + stamina + facing cell). - Requires cell inside a growth zone; kind must be in that zone’s
allowed_kindswhen the list is non-empty. - Applies transition with
agent: cultivateimmediately (channel), writing a terrain delta — not heartbeat-gated. - Does not create property or growth overlays.
8. Runtime terrain deltas
8.1 Store
terrain_deltas: HashMap<(i32, i32), TerrainDelta>
TerrainDelta { kind, tile_id?, elevation?, updated_tick }
sample_terrain / gfx: if delta exists for cell, it overrides authored zone for that cell.
Checkpoint with region world state as needed. Content-admin Publish / bake may coalesce deltas back into terrain_zones YAML when designers want disk canonical.
Later optimization: per-segment bloom — empty bloom ⇒ skip delta lookup.
8.2 Hybrid region heartbeat
Natural transitions must not scan every cell every tick.
- Each biome zone (and optionally growth zone / coarse chunk) tracks
last_active/last_visited. - Player presence, harvest, combat, or cultivate in/near the zone marks it hot.
- Degrade pass (aligned with WorldClock): only hot zones (configurable window — e.g. last N real minutes or M ticks).
- Weather state changes (e.g. enter drought) trigger a sweep of hot zones only.
- Cold zones: on next player entry, catch up once using elapsed time × applicable
weather_mult(current or averaged) so the world changed while away without paying for empty corners.
Settings sketch:
terrain_sim:
hot_window_ticks: 18000 # e.g. 10 minutes at 30 Hz
catch_up_on_enter: true
max_catch_up_transitions: 3 # cap cascading flips per cell per entry
9. Weather (regional)
Lightweight state per biome zone (not per cell):
| State | Role |
|---|---|
clear |
Baseline mults |
rain |
Slower arid degrade; faster wetland reclaim |
drought |
Faster grassland → bare / arid |
storm |
Tunable; may share rain-like mults |
Advanced beside WorldClock, biased by biome weather_weights. Drives:
- Transition
weather_mult - Growth respawn
weather_mult(37) - Optional vitals
DrainModifiervia biomedrain_tags(15)
Weather is a later system; catalogs and formulas reserve the hooks from day one.
10. Authoring procedural generation
10.1 Web Map layout (primary)
| Tool | Behavior |
|---|---|
| Generate / Fill | Seed, target biome mix, optional mask from selected growth/biome rects |
| Algorithm | Greedy place by adjacency + preferred families for biome; then heal forbidden seams |
| Output | Coalesced terrain_zones (same storage as paint) |
| Validate | List forbidden edges; optional auto-heal |
10.2 CLI
flatland-admin map generate --segment starter-plains --seed 42 --biome temperate_plains
Agents and CI use the same rules as the web tool.
10.3 Non-goals
Runtime exploration generation / infinite worlds — out of scope for this document.
11. Content-admin surfaces
| Surface | Responsibility |
|---|---|
| Map layout | Biome overlays; adjacency validate; Generate/Fill; transition preview; growth/tax/property (37) |
| Catalog / YAML | Biomes, adjacency, transitions (+ schemas); /api/catalog pickers |
| Native editor | Fine gfx pose / paperdoll / sprites; optional later validate parity |
Keep web Map layout current whenever world-placed fields grow — it is not a legacy fallback (content-admin-ui).
12. Sampling API (conceptual)
fn sample_cell(segment, deltas, x, y) -> CellSample {
terrain = deltas.get(cell) ?? terrain_zone_at(...)
biome = biome_at(...)
tax / property / growth = econ_at(...) // plan 37
weather = biome_zone.weather_state // when online
}
Movement uses merged terrain kind; harvest uses growth + weather_mult; cultivate checks growth + adjacency target.
13. Build order (implementation)
- Economic overlays — 37 with reserved
allowed_*andweather_mult = 1.0 - Biome catalog + biome_zones + adjacency — validate in web Map
- Transition catalog — all mults 1.0; no live mutate yet
- Generate / Fill + CLI — greedy + heal
- Cultivate + HashMap deltas + hybrid heartbeat
- Weather — flip real mults; drains; hot sweeps + cold catch-up
14. Exit criteria (full foundation)
- Biome zones load;
biome_atused by validate/generate - Adjacency forbids desert-family beside wetland/lush grass in generate
- Transition catalog parses with
weather_mult - Generate/Fill writes legal coalesced terrain for a masked region
- Cultivate inside growth writes a delta; outside growth fails
- Hot/cold heartbeat skips cold natural ticks; entry catch-up applies
- Weather (when present) only changes rates via catalog mults
15. Example adjacency snippet
# No lush grass glued to desert
rules:
- family: grassland
forbids: [arid]
- family: arid
forbids: [grassland, wetland, water]
allows: [arid, bare_soil, stone, path]
With procgen: a temperate biome fill prefers grassland/path; an arid biome fill prefers arid/bare_soil; shared borders use path/bare_soil as legal seams.