# Design notes (durable context) These are the non-obvious decisions/conventions that were previously only in Claude's auto-memory (which lives under `~/.claude/` and does **not** travel with the repo). Captured here so the context survives a move to another machine/server. `CLAUDE.md` has the authoritative current-state changelog; this is the "why / where things live" summary. ## Framing: World Creation → Live World The roadmap is no longer rigid numbered "phases". **World Creation** is a set of continuous, overlapping stages on a geological clock (My): tectonics → continental drift & erosion → hydrology → climate → biomes → (fauna & flora, next). The long-term goal is a separate **Live World** mode that runs the *finished* planet at a much slower real-time clock (hours/days/weeks/months) with dynamic weather (clouds, rain, storms) and living ecosystems/civilization. **Internal code still uses `phase*` names** (`Planet::drifting`, the `phase3` flag, `phase3AfterMy`/`phase3DtScale` config keys) for save/config compatibility — only display strings and docs use the new framing. ## Code module layout Split into a raylib-free **engine** (`src/sim/`, headless-testable) and a raylib **viewer** (`src/render/`); `src/main.cpp` is a ~10-line entry point. CMake adds both dirs to the include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`). `Planet` is **one class implemented across several .cpp files** (all share `Planet.hpp`): - `PlanetTypes.hpp` — `Cell` / `Plate` / `SubGrid` / `Biome` enum / `PlanetConfig`. - `Planet.cpp` — generation, geometry, plate seeding, RNG + shared helpers, subgrid, min/max. - `PlanetTectonics.cpp` — `step()` (stress→uplift→relax; orogeny boosts gated on `drifting`). - `PlanetDrift.cpp` — `cflDtMy`/`advect` + plate lifecycle (fission/kick/baby/fuse/enclosed). - `PlanetErosion.cpp` — `erode` + `adjustSeaLevel`. - `PlanetHydrology.cpp` — `routeFlow`/`computeHydrology`/`hydrology` (depression-fill→lakes, steepest-descent→rivers, mass-conserving stream-power incision). - `PlanetClimate.cpp` — `computeClimate()` (temperature + orographic precipitation). - `PlanetLive.cpp` — `computeInsolation()`/`computeLiveSeason()` (Live World: day/night + live seasonal temperature; derived, not saved). - `PlanetOcean.cpp` — moons (`generateMoons`, `moonDirection`/`sunDirection`/`moonOrbitNormal`) + `computeTides()` + `computeOceanCurrents()` (Live World sky, tides & currents). Moons saved (v9); tides/currents derived. - `PlanetWeather.cpp` — `initWeather`/`stepWeather` (Live World dynamic humidity/cloud/rain cycle; saved v10). - `PlanetBiomes.cpp` — `classifyBiomes()` (per-cell `Cell.biome` from elevation + climate). - `PlanetBiota.{hpp,cpp}` — Biota types + archetype table + slot/point draw + `computeBiotaDensity()`/`generateBiota()` (flora/fauna/funga). - `PlanetFloraGen.cpp` / `PlanetFaunaGen.cpp` / `PlanetFungiGen.cpp` — per-kind density + per-cell `fill*` (fauna gates carnivores on local prey; funga is moisture/organic-led). - `PlanetVolcano.cpp` — `placeVolcanoes`/`stepVolcanoes` (Live World stateful volcano lifecycle + islands; saved v15). - `NameGen.{hpp,cpp}` — deterministic procedural name generator (syllable banks; reused by the civ arc). - `PlanetGeography.{hpp,cpp}` — `generateGeography()` (named features: continents/oceans/ranges/ rivers/lakes; the atlas, saved v17+). - `PlanetEcoregions.{hpp,cpp}` — `generateEcoregions()` (named ecological provinces from biome, land/water context, productivity and broad biota; saved v19). - `PlanetCiv.{hpp,cpp}` — `computeHabitability`/`placeSettlements`/`stepCivilization` (civ Step 2: habitability + settlements that grow/decline on the live clock; saved v20). - `PlanetNation.{hpp,cpp}` — `computeTerritory` (civ Step 3: realms + per-cell ownership; derived, not saved). - `PlanetCulture.{hpp,cpp}` — `computeCultures` (civ Step 4: cultures/ethos/faith per continent + government per realm; derived, not saved). - `PlanetIO.cpp` — text config + binary save/load. The viewer is one `Viewer` struct: `Viewer.{hpp,cpp}` (state + setup + sim orchestration), `ViewerInput.cpp` (camera/picking/keys), `ViewerRender.cpp` (globe/map/panels/HUD/prompt), plus topical helpers `Colors` / `Map2D` / `Overlays` / `Picking` / `Panels`. Per-tick order in `Viewer::refreshView()`: `computeHydrology()` (if hydrology on) → `computeClimate()` → `classifyBiomes()` → `computeBiotaDensity()` → `recolor()`. The discrete biota *population* (`generateBiota()`) is NOT in this per-tick path — it's on-demand (key `L`). ## Core principle (do not violate) Geometry is **fixed** — cells (icosphere vertices) never move. Only per-cell *properties* flow over the fixed grid + neighbor adjacency (Eulerian). New phenomena = new per-cell fields flowed over the grid, never moving cells. ## Axial tilt render convention (non-obvious) The 3D globe is rendered leaned by `cfg.axialTilt` via `rlRotatef(tilt,0,0,1)` wrapping all 3D content in `renderGlobe3D`. Because that rotation isn't in the data, anything mapping between world and model space must compensate with `rotateZ(v, ±tilt)` (src/render/ Picking.cpp): 3D picking un-rotates the ray hit by `−tilt` before `nearestCell`; 3D plate labels rotate by `+tilt` before projecting. The 2D map + biome/climate are tilt-independent. ## Save format (v7) — self-describing config + biota population `planet.save` stores `PlanetConfig` as a **self-describing key=value text block** (not a raw POD dump), parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared in PlanetIO.cpp), written at `precision(17)` so doubles round-trip exactly. Consequence: **adding/removing PlanetConfig fields no longer breaks saves** (unknown keys ignored, missing keys keep defaults). v6 cannot load pre-v6 saves (one-time break; a length guard fails it gracefully). Per-cell `Cell.biome` is saved (a byte appended after `invader`). **v7** appends the **biota population** (`sBiota`): a flag byte, then three `Organism{uint16 archetype, uint8 biome}` lists per cell. Densities are derived (not saved). Older saves without the block load fine with an empty population (`readState(is, hasBiome, hasBiota)`; `hasBiota = ver>=7`). ## Biota (flora / fauna / funga) — density + slot/point population Two layers (`PlanetBiota.cpp` + the three `*Gen.cpp`): (1) derived per-cell **density** scalars (0..1) recomputed each tick like climate — flora = Liebig-min(temp, moisture), fauna ∝ flora (carnivores gated on neighbourhood prey ≥ `bioCarnPreyMin`), funga = moisture/organic-matter-led + cold-tolerant. All three are 0 under polar `Ice`; funga is also 0 on water, while flora/fauna extend into the ocean as marine productivity (see below). (2) On-demand discrete **population** `generateBiota()`: each land **or ocean** cell draws broad archetypes from the comprehensive append-only `biotaArchetypes()` table into a per-kind slot cap + density-scaled point budget (size → cost Tiny=1…Huge=5), weighted by biome/climate suitability and a **regional bonus** for archetypes already in same-biome neighbours (single index-ordered pass → homogeneous regions, boundary variety). Organisms are labelled by **taxonomy** — Family + Size + role (full `Class > Order > Family` in `organismTaxonomy()`), never informal common names ("Felidae", not "cat"); generalist families get a biome adjective ("Desert Muridae"). Generation uses a **separate RNG seeded from `cfg.seed`** (not `Planet::rngState`) so populating biota never perturbs tectonic determinism — asserted in `test_biota.cpp`. The archetype table is **append-only** (indices are serialized in v7 saves). **Marine flora & fauna (life in the ocean).** Funga stays land-only, but flora and fauna now extend over water (the old hard `elevation<=sea` gate left the sea barren). Ocean cells (not under polar `Ice`) get a marine primary productivity in `computeFloraDensity`: `base + (1-base)·max(shelf, coast)`, where `shelf = clamp(1 - depth/bioMarineShelfDepth)` (light to the photic floor) and `coast = clamp(1 - ringDistFromLand/bioMarineCoastRings)` (land-runoff nutrients, a multi-source BFS ring-distance seeded from land — the continentality BFS mirrored). `sMoist` is a land rainfall field and is not used at sea. `computeFaunaDensity` now skips only `Ice`, so marine fauna = `flora·productivity` and the carnivore prey-gate clusters sharks/seals/squid on rich shelves. `generateBiota` fills ocean cells too (skip `Ice`; no marine funga); `fillFlora`/ `fillFauna` are unchanged because their biome-mask filter draws only the **Ocean-masked** archetypes appended to the table (Kelp/Seagrass/Phytoplankton; Forage fish/Reef fish/Shark/Baleen whale/Seal/ Squid — all `moistMin=0`, SST-zoned). The flora/fauna colour views render ocean on a distinct marine ramp (`marineFloraColor`/`marineFaunaColor`). Knobs: `bioMarineBase`/`bioMarineShelfDepth`/ `bioMarineCoastRings`. No save bump (densities derived; archetypes append-only; config self-describing). ## Climate + biome model (derived, not saved) `computeClimate()` builds two derived per-cell fields: - **Temperature** (°C) = latitude curve (`biomeEquatorTemp/PoleDrop/LatExp`, super-linear so cold concentrates at poles) − `biomeElevLapse` × elevation. This is the **annual mean**; the **Seasons** pass adds derived `sTempSummer`/`sTempWinter` = mean ± `A`, where the seasonal half-amplitude `A = seasonAmpMax · sin(axialTilt)/sin(23.44°) · latShape · continentality`. Continentality is a multi-source-BFS ring distance from ocean cells (oceans/coasts muted by thermal inertia; interiors swing most). `classifyBiomes()` blends winter temp into the Tundra/Taiga cold cutoffs via `biomeSeasonWeight` (0 = mean only → unchanged biomes), so cold-winter continental interiors turn boreal/tundra. Seasonal fields are derived/not-saved. **Ocean currents** add a bounded coastal warm/cold anomaly to this mean before the seasons pass (`climateCurrentFactor`; see the Ocean section). - **Precipitation**: zonal prevailing winds (easterly tropics/poles, westerly mid-lat); ocean cells are a moisture source; each land cell takes its **upwind** neighbour's moisture, rains out more on windward upslopes (orographic), loses a multiplicative fraction per cell (continentality) → leeward/interior drying. The raw field is near-binary, so it's **diffused** (`climateMoistureSmooth` passes) into transition zones, then normalized to `sMoist∈[0,1]` by anchoring the **median land precip → 0.5** (robust to orographic spikes). `classifyBiomes()` reads `sTemp` + `sMoist` (not a latitude hack) → rain-shadow/interior deserts emerge; 13 biomes incl. polar Ice; wetlands require water adjacency. All biome & climate thresholds are tunable `biome*` / `climate*` keys in `planet.cfg`. ## Live World (slow real-time clock) — day/night + live seasons (derived, not saved) The arc after World Creation: the finished planet runs on a slow real-time clock instead of the geological My clock. `PlanetLive.cpp` (raylib-free) builds two derived per-cell fields, recomputed each frame like climate (never saved): - `computeInsolation(dayOfYear01, timeOfDay01)` → `sInsolation` ∈ [0,1], the instantaneous solar incidence `max(0, cell.unit · sunDir)`. `sunDir = lonLatToDir(λ, δ)` with declination `δ = axialTilt·sin(2π·dayOfYear01)` (0 at equinox, ±tilt at solstice → polar day/night) and sub-solar longitude `λ = π·(1−2·timeOfDay01)` sweeping once per day. **This is the hook the future weather sim reads** (daytime heating). Computed in **model space** (the fixed cell units) so it stays consistent with both the tilted 3D globe (the lit pattern rotates with the globe; the seasonal lean is carried by `δ`, not the render tilt) and the model-space 2D map. - `computeLiveSeason(dayOfYear01)` → `sLiveTemp`, the annual-mean `sTemp` swung toward the static `summerTemp`/`winterTemp` by the seasonal phase `g = sin(2π·doy)·sign(lat)` (`liveTemp = mean + A·g`, `A = (summer−winter)/2`), anti-phased across hemispheres. Viewer (Eulerian, geometry fixed — all overlays are per-cell render passes): key `W` (settled world) toggles `liveWorld`; drift freezes and `liveTime` (hours) advances at `liveRate` (sim hours/real-second, ramped hour→month with `[`/`]`). `rebuildLiveOverlay()` builds `illum` (soft day/night terminator over `sInsolation`, dim night floor) + `shadedColors` (base colour → snow on cold land / sea-ice on cold ocean via `snowTemp`/`seaIceTemp` → day/night dim); both the 3D globe and 2D map draw `displayColors()` (the overlay over **any** colour mode). `N` toggles the terminator. Save **v8** appends a Live World flag + `liveTime` (header, version-gated). Knobs: `dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp` in `planet.cfg`. ## Moons & tides (Live World sky/oceans) `PlanetOcean.cpp` (raylib-free): `generateMoons()` seeds **1–3 `Moon`s** from a **separate RNG** (`cfg.seed ^ 0x900D5EED`) so it never touches the tectonic `rngState` — moons are world objects (not cells) and are **saved (v9)** via `writeState`/`readState(..., hasMoons)` (pre-v9 saves synthesize them from the seed). Sky geometry is one source of truth: `sunDirection(doy,tod)` = celestial dir leaned by declination then spun `-2π·tod` about +Y; `moonDirection(i,tod,days)` = inclined orbit circle `Ω=2π·days/period+phase` then the same spin (so a fixed cell sees ≈one lunar pass/day). `computeInsolation` now calls `sunDirection`. **Tides** (`computeTides` → `sTide`, derived/not saved): equilibrium two-bulge potential `Σ_body w·(cosθ²−⅓)` over the moons (weight `tideWeight`) + sun (`tideSunFactor`), scaled `tideAmplitude` — zero-mean, high under a body and its antipode, low at 90°, sweeping ≈twice/day. Render (Viewer): the coastline is traced once per terrain change (`buildCoastline`, dual-contour on the land/ocean split, recording the adjacent ocean cell per segment) and coloured by `tideColor(sTide[oceanCell])` (`T`; auto-scaled), in 3D + 2D. The 3D sun is small/distant with a halo; moons render at a visible orbit band with a sun-lit **phase** (offset-dark-sphere trick), faint **orbit rings** (great circle ⟂ `moonOrbitNormal`), and **eclipses** — solar darkens a spot in `rebuildLiveOverlay`'s `illum` near the sub-solar point when a moon transits the sun; lunar dims a moon reddish in the planet's shadow. **Ocean currents** (`computeOceanCurrents`, also `PlanetOcean.cpp`): a per-ocean-cell tangent velocity `sCurrent` from wind stress (`sWind`) rotated by a **Coriolis** deflection (right N / left S about the cell normal), with the across-shore component removed at land neighbours so the stream follows the coast (gyres), then smoothed and re-tangented (zero on land). `computeClimate` calls it right after the wind pass and feeds **warm (poleward) / cold (equatorward)** currents back into `sTemp` as a bounded coastal anomaly (`climateCurrentFactor`, smoothed onto coasts, applied before seasons → biomes shift with it). Rendered as warm/cold arrows over the sea (`buildCurrents`, key `O`). Currents/feedback are derived (not saved). ## Weather (Live World dynamic clouds & rain) `PlanetWeather.cpp` advances a per-cell **humidity / cloud / rain** cycle on the live clock (`stepWeather(dtHours)`), time-varying unlike the static climate. One step: **evaporate** over warm sunlit ocean (relax humidity toward a marine target scaled by `sTemp` warmth + `sInsolation` daytime), **advect** humidity & cloud downwind (upwind differencing along `sWind`/`sUpwind`, speed `weatherWindKmh`), **condense** the supersaturated air into cloud (saturation `weatherSatBase + weatherSatTempCoef·T`, plus windward orographic lift), **rain** out cloud above `weatherRainThresh`, then **dissipate** (half returns to humidity). All rate terms use bounded `1−exp(−rate·dt)` forms so it's stable at any timestep (the clock can run hours→months/sec). `initWeather()` seeds it from the moisture climatology. Deterministic (no RNG). Driven each live frame from `Viewer::stepSim` with dt = the sim-hours added to `liveTime` (0 when paused). Render: a translucent **cloud shell** over the 3D globe (white → dark slate where it rains, alpha = cover, a second triangle layer at `visBase+0.03`) and a matching `drawWeather2D` layer on the 2D map (shared `drawMapTris` rasterizer), toggled with `K`. Saved as **v10** (humidity/cloud/rain, flag-gated; pre-v10 saves spin weather up on entering Live World). **Moving weather systems** (same `stepWeather`): the base field above relaxes to a *static* pattern under fixed forcing, so a population of drifting `WeatherSystem` **agents** (world objects, not cells — like moons; transient/not saved; separate `sWeatherRng` seeded from `cfg.seed`) provides the motion. Each step they **spawn** over warm tropical ocean (5–25°) or a mid-latitude (30–62°) ocean low, **move** along the steering wind (`sWind` at the nearest cell) + a poleward recurve (`weatherSystemSpeed`), **intensify** over warm sea / **decay+cull** over land/cold, and **stamp** a Gaussian cloud/rain shield onto the grid — so cloud clusters travel and dissipate behind them. Tropical systems past `weatherHurricaneStr` are hurricanes/typhoons; rendered as animated cyclonic spiral markers (eye for cyclones) spinning by hemisphere, in 3D + 2D, under `K`. The systems (+ their RNG/next-id) are **saved (v11)** alongside the humidity/cloud/rain fields, so a load resumes active storms; a load also drops any stale pre-load `wxUndo` step-back ring and (v12) restores the most recent `wxSaveMax`(40) step-back frames from the file, so stepping back after a load can rewind storms past the saved moment. v13 also saves the Live World clock rate. Weather is integrated/path-dependent, so reversing it past a save is only possible via this stored history — it can't be re-derived from the loaded moment. ## Volcanoes & volcanic islands (Live World) `PlanetVolcano.cpp`. A `Volcano` is a fixed point on the grid (one `cell`) and a stateful lifecycle agent. On **entering Live World** `placeVolcanoes(liveTime)` seeds a set once, by tectonic context: a cell is **ridge** if its plate is `baby` or a neighbour's is (the `buildBorders` baby test), else **border** if a neighbour has a different `plateId`, else **interior**; placement probability is `volcanoProbRidge` ≫ `volcanoProbBorder` ≫ `volcanoProbInterior`, reservoir-sampled to `volcanoMaxCount` (an unbiased subset, ratios preserved). A separate RNG (`sVolRng = cfg.seed ^ 0x70C4F12A`) keeps the tectonic stream untouched. Each vent stores `baseElev`, current `built` height, `phase` (growing/dormant), dormancy and ash timers, ash carry, and decaying `activity`. The crucial design point is now the opposite of the original v14 implementation: **volcanoes are integrated forward**, not pure functions of `liveTime`. `stepVolcanoes(dt)` grows active vents by `volcanoBuildRate * activity`, lets tall vents go dormant above `volcanoFreeHeight`, explodes dormant vents after a long timer, shaves `volcanoExplodeDropFrac` of built height, starts sustained ash emission, and decays activity so old volcanoes settle. It always reasserts `cells[v.cell].elevation = baseElev + built`; submarine vents crossing sea level breach into islands and can re-submerge when a restored snapshot has less built height. Explosions stamp a wide local ash blast into `sCloud`/`sHumidity` and sustained puffs continue while `ashTimer` runs. Because this is stochastic state, **step-back snapshots include volcanoes + `sVolRng`** alongside weather. A backward step restores weather, storms, volcano lifecycle state and RNG, then `stepVolcanoes(0)` reasserts terrain without advancing. Rendered as growing red/orange cones, dormant grey quiet cones, and bright post-explosion plume markers (3D + 2D, key `V`). **Saved v15**: the stateful `Volcano` set + `sVolRng`; v14's old pure-function block is consumed and discarded so volcanoes reseed on the next Live World entry. Knobs: `volcano*`. ## Live World viewer controls (follow-cam, 2D zoom, clock stepper) Three viewer-only controls over the Live World sim: - **Storm follow-cam** (`Y`): the globe is at the origin and the camera orbits it, so to centre a storm we point the camera **along the storm's world direction** — `rotateZ(storm.pos, +axialTilt)` (model→world; `Picking.hpp`), then `camPitch=asin(d.y)`, `camYaw=atan2(d.x,d.z)`. Tracked by a stable `WeatherSystem.id` (assigned at spawn; transient, no RNG/determinism impact). Orbit-drag is disabled while following; wheel-zoom still works; cycles by descending strength, auto-releases if the storm dissipates. - **2D map zoom** (`mapZoom`/`mapPanX`/`mapPanY`): implemented as a **virtual projection rect**, `Viewer::mapViewRect()` = `mapRect` scaled about its centre + pan. Every map projection call (`drawMap2D`/`drawWeather2D`/`drawSegments2D`/graticule/markers/`mapScreen` + the 2D hover-pick) takes this `vr` instead of `mapRect`, while the **scissor + frame stay `mapRect`** so it clips to the panel — no Map2D signature changes. `drawMapTris` was changed to derive the y-coordinate from the rect (not the fixed-to-mapRect `m.pos`) so both axes zoom. Wheel zooms toward the cursor (1–8×); drag pans when zoomed, else keeps the `mapLon` longitude rotation. - **Clock stepper**: the `stepSim` Live-World body is factored into `Viewer::liveAdvance(dtClock, dtWeather)` (clamps `liveTime≥0`, recomputes insolation/season/tides/moons, `stepWeather`, overlay). `.`/`,` step ±`liveRate` hours and **auto-pause** (frame-step). Weather is integrated and not analytically reversible, so a forward step snapshots the full weather state (`Planet::captureWeather`/`restoreWeather` — humidity/cloud/rain/storms/RNG) into a bounded `wxUndo` ring; **`,` restores the newest snapshot at or before now**, reversing clouds/rain/storms exactly as well as the deterministic sky. `liveAdvance` records a snapshot at ~one-step cadence on *any* forward advance — continuous run or manual step — so storms born during a run also rewind (the ring is bounded, ~one snapshot per real second since the interval scales with `liveRate`). The restored snapshot includes the storm RNG, so re-stepping forward replays deterministically. ## Live World event journal `Viewer` owns a saved, bounded event journal (`WorldEvent`, newest 200) shown in the tabbed `liveInfoRect` panel beside the 2D map (`Sky` / `Tides` / `Weather` / `Events`). It is intentionally viewer-level state: the sim emits no UI strings, and the log is **not** part of step-back history. Rewinding restores weather/storms/volcanoes, but the journal remains the observer's record. Events are detected in `Viewer::liveAdvance` by comparing before/after Live World state: weather system formation, tropical systems crossing hurricane/typhoon strength, volcano dormancy, dormant volcano eruptions, and submarine volcanoes breaching into islands. Clicking an event calls `focusCell`: select/rebuild the cell detail, release storm follow-cam, rotate the 3D camera to the cell using the same axial-tilt convention as picking, and centre the 2D map at the current zoom. Save **v16** appends the event log; pre-v16 saves load with an empty journal. ## Geography & place-names — the atlas (civilization Step 1) `PlanetGeography.cpp` + `NameGen.cpp` (engine, raylib-free, deterministic). The first step of the civilization arc: name the world so everything civic can reference it. `Planet::generateGeography()` extracts geographic features from the frozen terrain purely by **connectivity over the fixed grid** (the same flood-fill idiom as `coalesceBabyPlates` / the enclosed-sea fill): connected land → **Continent** (≥ `geoContinentMinCells`) or **Island**; the connected world ocean is split into **basins** (a single connected body reads wrong as one name) by a distance-from-land watershed — greedy farthest-first deep-water **centres** (`geoOceanSepRadians` apart, ≥ `geoOceanDeep` rings from land) then multi-source BFS Voronoi over the ocean graph — each basin → an **Ocean** (or **Sea** if ≤ `geoSeaMaxCells`); inland filled basins (`lakeDepth`) → **Lake**; connected `> geoMountainElev` land → **MountainRange** + its highest cell as a **Peak**; the largest `discharge` mouths traced upstream via `flowTo` → **River**. It first calls `computeHydrology()` (routing only — no elevation change) so the river/lake fields exist on a finished world. Naming is a separate concern in `NameGen` (syllable banks; `bankForRegion` gives each continent a "language" so its rivers/mountains share a sound) and uses a separate RNG (`sGeoRng = cfg.seed ^ magic`) + a per-feature hash, so it is deterministic and **never perturbs the tectonic stream** (asserted in `test_geography.cpp`). Output: `Planet::geoFeatures` (id/kind/name/anchorCell/regionId/ size) plus four per-cell index arrays (`sCellLand`/`sCellWater`/`sCellRange`/`sCellRiver`) giving O(1) "which features is this cell in" — the hook the later territory/border step will build on. Geography is static (terrain is frozen), so it is generated **once** on a settled world (key `M`, in or out of Live World) and **saved (v17+)** — names persist so a future culture step can rename places. The viewer draws names as labels on the globe (the plate-label manual projection) + 2D map (minor features only when zoomed, to declutter), lists them in an **Atlas** tab (5th live-info tab; click a row → `focusCell`), and adds a "region" line to cell-info. Save v17 appends the feature records (with `std::string` names, written field-by-field) + the POD index arrays; pre-v17 saves load with none and regenerate on demand. Save v18 appends the active geography reshuffle salt (`Shift+M`) so repeated renames continue after load. Names dedupe on the **proper-noun root** (not the formatted string), so a continent, its river and its mountains can't share a base name. New land created during Live World (a volcanic island breaching the sea) is added to the atlas on the fly by `Planet::nameNewLand(cell)` — it joins an adjacent existing landmass or mints a fresh unique Island name, which the island-formation `WorldEvent` then carries. ## Ecoregions — named ecological provinces `PlanetEcoregions.cpp` (engine, raylib-free, deterministic) adds the next atlas-like layer after geography. `Planet::generateEcoregions()` ensures climate/biomes, biota density, hydrology and geography exist, then flood-fills connected cells by **biome + land/ocean/wet context + similar productivity band**. Tiny fragments merge into an adjacent compatible region when possible. Each `Ecoregion` stores a name, biome, anchor cell, containing geography feature id, size, average flora/fauna/funga productivity and dominant broad flora/fauna/funga archetype. If the discrete biota population exists (`L`), dominants come from the actual placed organisms; otherwise they are inferred from density + archetype suitability. Ecoregions summarize existing broad ecology — they do not create new species or a food-web simulation. Names use `NameGen` with the containing geography bank, so ecological names inherit regional sound without touching the tectonic RNG. Viewer key `E` lazily generates/toggles the ecoregion colour view, cell-info shows the local ecoregion and dominants, and Live World has an **Eco** tab (6th tab; click a row → `focusCell`). Save **v19** appends the ecoregion records and `sCellEcoregion`; pre-v19 saves load with none and regenerate on demand. ## Civilization Step 2 — habitability & settlements `PlanetCiv.cpp` (engine, raylib-free, deterministic, separate `sCivRng`). `computeHabitability()` is a derived per-cell food/livability score (0..1): a weighted blend of temperature comfort, water access (river `discharge`, adjacent lake, coast) and food (`floraDensity`+`faunaDensity`+the cell's ecoregion productivity), gated by freezing winters and high elevation. `placeSettlements()` (key `U`, "the dawn") seeds a **fixed** set once by **habitability-weighted random sampling** (weight = habitability^`civClusterExp`) with a **soft Gaussian suppression** (`civMinSpacingRadians`) softening nearby weights after each pick — so settlements **cluster** on good land at irregular spacing instead of an even lattice (the earlier hard farthest-first looked like a grid). Each is named from its continent's `NameGen` bank. Because placement is one-time, the settlement *set* never changes, so the only mutable per-step state is each settlement's **population** — which is all the step-back snapshot stores (a `vector` in `WeatherSnapshot`, restored in `restoreWeather`; no per-frame string churn). `stepCivilization(dtHours, liveTime)` runs in `liveAdvance` after `stepVolcanoes`. It is **environment-driven and dynamic** (the first cut grew every town uniformly to the same cap): - growth **rate** `r = civGrowthRate·(civGrowthMin + (1−civGrowthMin)·habitability)` so fertile cells grow far faster than marginal ones; - carrying capacity `K = civMaxPopulation · habitability · siteQuality · conditions`, where **siteQuality** = `0.45 + civSiteVariety·(coastBonus + log10(1+discharge/20))` makes max city size vary by an order of magnitude (a continental river or coast → a metropolis, a dry inland cell → a town — this is what spreads final sizes instead of all saturating equally); - **conditions** = `harvest · drought · coldYear · flood · ash`, all **deterministic functions of (≈20° region bucket, integer year, seed)** — constant within a year, region-correlated, recomputed on a step-back (pure, so no extra saved/snapshot state): year-to-year harvests (swing scaled by the seasonal-amplitude/continentality field), multi-year droughts (a slow noise interpolated across `civDroughtPeriod`-year epochs, threshold raised by aridity `1−sMoist`), rare cold years (× the cell's near-freezing-winter exposure), river floods (silt bonus / rare disaster), and the volcano-ash cut. - **Storms** read live `storms()` (already snapshotted): a system within its `radius` of a town deals direct deaths `civStormDeathRate·strength·overlap·(hurricane? civHurricaneDeathMult)` — a parked hurricane can gut a coastal city. - logistic step + an accelerated `civFamineRate` loss when `K devastates X" (reuses `weatherEventName`), else "Famine shrinks X to a Town" when `sCivDrought` is high, else tier up/down/abandon. Markers (3D spheres + 2D dots, sized by tier; city/town 3D labels), a **Civ** tab (7th), a cell-info line, and a `Habitability` colour mode (key `I`). `buildGeometry()` clears the set on reseed. Save **v20** stores the settlement records (population included); `sCellSettlement` is rebuilt on load. Knobs `civ*`. ## Civilization Step 3 — territory, nations & political borders `PlanetNation.cpp` (engine, raylib-free, **no RNG** → tectonic stream untouched). `computeTerritory()` turns the settlement set into **realms** + per-cell ownership, all a **pure deterministic function of the (saved) settlements** — so it is *recomputed*, never saved (no `SAVE_VERSION` bump), and step-back replays it as populations restore. (1) **Influence range** per living settlement scales with population (`civTerritory*`). (2) **Realm grouping**: process settlements largest→smallest; a settlement joins the nearest larger **capital** whose annexation reach (`civVassalRange × its range`) covers it (→ a vassal town of that kingdom) else founds its own nation; tier City-state / Kingdom / Empire by member count / total pop. (3) **Per-cell ownership**: each land cell goes to the settlement maximising `range − angular-distance` if > 0, else **wilderness** (−1) — influence-limited, with frontiers; the cell's nation is that settlement's. Peaceful + population-driven (no conquest yet). Render: a `Territory` colour mode (`nationColor`, golden-ratio HSV) + dark **border lines** (`buildNationBorders` — the plate dual-contour reused, keyed on `cellNation()`) + realm labels at capitals (key `P`), a **Realms** tab (8th), a cell-info realm line, and **kind=4** `WorldEvent`s (realm founded / rises to an empire / collapsed — detected in `detectNationEvents` by matching nations across a recompute by capital id). The viewer recomputes territory + rebuilds borders **once per sim year** (`liveAdvance` year-tick), and on placement / load / step-back; `buildGeometry()` clears `nations`/`sCellNation`/`sSettleNation` on reseed. Knobs `civTerritory*`/`civVassal*`/`civEmpire*`. ## Civilization Step 4 — culture, beliefs & governments `PlanetCulture.cpp` (engine, raylib-free, **no RNG** → tectonic stream untouched). `computeCultures()` runs right after `computeTerritory()` (it reads `nations` / `sCellNation`) and is a **pure deterministic function** of the settlement set + geography + biomes — so it is *recomputed*, never saved (no `SAVE_VERSION` bump), and step-back replays it for free. (1) **One culture per inhabited continent**: living settlements are grouped by `Settlement.regionId` (its continent geography-feature; fall back to the language `bank` when there's no geography). Each `Culture` gets a **people name** (`namegen::makeName` from the continent's bank), a dominant **ethos** = argmax of environmental fractions over its settlement cells (coastal→Seafaring, mountains/hills→Highland, desert/dry→Nomadic, fertile-biome→Agrarian; if none ≥ 0.34, a `cultHash` pick of Mercantile/Warlike/Agrarian), and a **religion** — a `Faith` focus from the dominant biome (coast→Sea, mountains→Sky, desert→Sun, cold→Ancestors, forest/wetland→Harvest, with a rare hash Moon/War) plus a generated `faithName`. (2) **Government per realm**: each `Nation` gets a `GovType` from its tier + a `cultHash` pick and its `name` is **rewritten** to fold it in ("Republic of X", "Duchy of X", "X Theocracy", "X Confederation", "X Dominion"…); `nation.cultureId` = its capital's culture. (3) **Per-cell culture** `sCellCulture[c]` = the culture of `sCellNation[c]`'s nation (reuses territory's ownership) → the culture view shows **cultural blocs** over the political map. **Culture-driven grouping**: `computeTerritory()`'s realm-join test now requires the vassal and its candidate capital to share a `regionId`, so every realm is **mono-cultural** (no kingdom spans two continents). Render: a `Culture` colour mode (`cultureColor`) + pale `buildCultureBorders` lines + a `Cultures` tab (9th) + a cell-info culture/faith line + government-aware realm labels, all under key `X`; `rebuildTerritory()` computes territory **then** cultures and rebuilds both border sets each sim year / placement / load / step-back. No config knobs (rules are constants in `computeCultures()`). ## Headless testing Engine is raylib-free, so logic is tested without a display. Build/run: ``` g++ -std=c++17 -O2 -Isrc/sim test_logic.cpp src/sim/IcoSphere.cpp src/sim/Planet.cpp \ src/sim/PlanetTectonics.cpp src/sim/PlanetDrift.cpp src/sim/PlanetErosion.cpp \ src/sim/PlanetHydrology.cpp src/sim/PlanetBiomes.cpp src/sim/PlanetClimate.cpp \ src/sim/PlanetLive.cpp src/sim/PlanetOcean.cpp src/sim/PlanetWeather.cpp \ src/sim/PlanetVolcano.cpp \ src/sim/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \ src/sim/PlanetFungiGen.cpp src/sim/NameGen.cpp src/sim/PlanetGeography.cpp \ src/sim/PlanetEcoregions.cpp src/sim/PlanetCiv.cpp src/sim/PlanetNation.cpp \ src/sim/PlanetCulture.cpp \ src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t # test_biota / test_live / test_ocean / test_weather / test_volcano / test_geography / test_ecoregions / # test_civ / test_nation / test_culture use the same source list. ``` (add new `src/sim/*.cpp` to that list as stages are added). `Planet::step()` passes are data-parallel + double-buffered → bit-identical for any OpenMP thread count (determinism).