planetsim/docs/design-notes.md
Jonas Reith 91f1b90a93 Civ Step 4: culture, beliefs & governments
Give the world peoples and faiths on top of Step 3's realms. All derived
deterministically from the (saved) settlement set + geography (pure hashes,
no RNG, recomputed each sim year -> no save-version bump, step-back free).

- PlanetCulture.{hpp,cpp}: computeCultures() groups living settlements into one
  culture per inhabited continent, each with a generated people name, a dominant
  ethos picked from its cells' environment (Seafaring/Highland/Nomadic/Agrarian,
  else a hashed Mercantile/Warlike), and a religion (a Faith focus biased by the
  dominant biome + a generated faith name). Runs after computeTerritory().
- Governments: each realm (Nation) gets a GovType from its tier + a deterministic
  pick, folded into its name ("Republic of X", "Duchy of X", "X Theocracy", "X
  Confederation", "X Dominion", ...) -- culture-driven renaming.
- Culture-driven grouping: realm vassalage is now restricted to the same
  continent, so every kingdom/empire is mono-cultural.
- Render: a Culture colour mode (cultureColor, cultural blocs) + pale
  buildCultureBorders lines + a Cultures tab + a cell-info culture/faith line +
  government-aware realm labels, all under key X. Cultures recompute with
  territory in rebuildTerritory().
- test_culture.cpp: one culture per continent, valid ethos/faith, governments per
  tier reflected in names, mono-cultural realms, determinism, RNG isolation,
  save->load->recompute parity. All 12 suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 07:58:58 +02:00

36 KiB
Raw Blame History

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.hppCell / Plate / SubGrid / Biome enum / PlanetConfig.
  • Planet.cpp — generation, geometry, plate seeding, RNG + shared helpers, subgrid, min/max.
  • PlanetTectonics.cppstep() (stress→uplift→relax; orogeny boosts gated on drifting).
  • PlanetDrift.cppcflDtMy/advect + plate lifecycle (fission/kick/baby/fuse/enclosed).
  • PlanetErosion.cpperode + adjustSeaLevel.
  • PlanetHydrology.cpprouteFlow/computeHydrology/hydrology (depression-fill→lakes, steepest-descent→rivers, mass-conserving stream-power incision).
  • PlanetClimate.cppcomputeClimate() (temperature + orographic precipitation).
  • PlanetLive.cppcomputeInsolation()/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.cppinitWeather/stepWeather (Live World dynamic humidity/cloud/rain cycle; saved v10).
  • PlanetBiomes.cppclassifyBiomes() (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.cppplaceVolcanoes/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 λ = π·(12·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 = (summerwinter)/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 13 Moons 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 (computeTidessTide, 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 1exp(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 (525°) or a mid-latitude (3062°) 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 volcanoProbRidgevolcanoProbBordervolcanoProbInterior, 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 directionrotateZ(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 (18×); 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 flowToRiver. 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<double> 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 + (1civGrowthMin)·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 1sMoist), 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<P + the storm deaths; floored at 1 so a site revives. So towns grow, fluctuate, shrink in droughts, and collapse/abandon under sustained famine or an acute disaster. Per-settlement derived sCivCond (combined multiplier) + sCivDrought (severity) drive the viewer: markers are withered-tinted by hardship, cell-info shows "drought/conditions", and detectLiveEvents logs kind=3 events with the cause — a storm over the shrunk town → "Hurricane 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 WorldEvents (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).