planetsim/CLAUDE.md
Jonas Reith 3655dc0661 Civ settlements: environment-driven dynamic growth (droughts, harvests, storms)
Settlements were static -- every town grew with one global rate to the same
habitability cap (frozen in Live World), so they grew "the same amount
everywhere". Now stepCivilization(dtHours, liveTime) is environment-driven and
time-varying. All drivers are deterministic functions of (region, year, seed),
so step-back replays them with no new saved/snapshot state (population is
already snapshotted).

- Differentiated growth: rate scales with habitability (fertile boom, marginal
  crawl); capacity K = maxPop * habitability * siteQuality * conditions.
- Site quality: max city size varies by an order of magnitude with location --
  a great river (log-scaled discharge) or coast hosts a metropolis, a dry inland
  cell a town (civSiteVariety). This spreads final sizes ~8-9x instead of all
  saturating equally.
- Conditions (per ~20deg region, per year): multi-year droughts (worse in arid
  regions), year-to-year harvests (bigger swings inland), rare cold years, river
  floods, and the existing volcano ash. Regional, so different parts of a
  continent have different fortunes.
- Storms kill directly: a system within its radius of a town deals deaths scaled
  by strength (hurricanes x civHurricaneDeathMult) -- a parked hurricane can gut
  a coastal city. Reads the already-snapshotted storms().
- Famine: accelerated loss when food < population; sustained drought / acute
  disaster can collapse a settlement to ruins (revives when conditions return).
- Viewer: markers withered-tinted by hardship (3D + 2D), cell-info "drought/
  conditions" line, and cause-attributed kind=3 events ("Hurricane X devastates
  Y", "Famine shrinks Y to a Town", tier up/down/abandon).

New civ* knobs (self-describing config -> no save bump). test_civ extended:
sizes vary widely (not uniform), bad-year declines, a hurricane over a town
kills its population. All 10 suites pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:26:56 +02:00

958 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
Guidance for Claude Code when working in this repository.
## Project
A C++/raylib simulation/game for creating semi-realistic fantasy & sci-fi
worlds/planets. Built in phases. Between phases the user can edit the world
or trigger events (meteor impact, magical events, sci-fi terraforming).
**Core principle:** the planet geometry is FIXED. Cells (vertices/faces) never
move. Only their *properties* flow over the fixed grid (Eulerian, not
Lagrangian). This keeps the data structure stable across all phases and makes
erosion, climate and drift far simpler to implement later.
## Roadmap
The project has two big arcs. **World Creation** builds a plausible planet through a
set of geological/environmental stages that **overlap and run together on a geological
clock** (My) — they are not strict sequential "phases" (the code keeps `phase*` names
internally for save/config compatibility, but think of them as continuous stages). Once
a world is "done", the long-term goal is a separate **Live World** mode that runs the
finished planet at a *much* slower, real-time-ish clock (hours/days/weeks/months) with
dynamic weather and life.
**World Creation (geological clock, mostly done):**
- **Tectonics & landmass** *(done)* — icosphere geometry, plate assignment, boundary
stress forming mountains (convergent) and trenches/rifts (divergent). The initial
"forming" pass settles to isostatic equilibrium, then plate motion continues.
- **Continental drift & erosion** *(done)* — real plate motion (cm/yr, My), the plate
lifecycle (fission, stalemate kick, spreading-born plates, merging), diffusive
erosion + a sea-level controller (~30% land), taller persistent mountains
(collision + arc + isostatic persistence), seafloor aging→depth. `planet.cfg` +
`planet.save`. See [[phase2-design-direction]].
- **Hydrology** *(done)* — rivers, lakes and fluvial erosion as a macro drainage network
(depression-fill→lakes, steepest-descent→rivers, mass-conserving stream-power
incision). Runs at a finer timestep alongside continuing drift. See [[phase3-hydrology]].
- **Climate** *(done)* — continuous per-cell temperature + orographic precipitation
(`Planet::computeClimate`); runs from forming onward (a live "base climate" that
updates as terrain changes). Color modes `6`/`7`.
- **Biomes** *(done)* — per-cell `Cell.biome` (13 biomes incl. polar Ice) from elevation
+ the climate fields (`Planet::classifyBiomes`), color mode `5`, saved per cell.
- **Biota — flora, fauna & funga** *(done — see `docs/fauna-flora-plan.md` +
`docs/fauna_generation_plan.md`)* — two layers: per-cell **density scalars** (flora,
fauna, funga ∈ [0,1]) derived from the climate fields each tick (drive the colour views),
plus a discrete **slot/point population** of broad archetypes (Class/Order/Family/Size),
generated **on demand** (`L`) and **saved** (save v7). Fauna is a herbivore/carnivore/
omnivore food chain (predators gated on local prey); funga uses a flora-like but
moisture/organic-matter-led rule. The living/evolving ecosystem is reserved for Live World.
- **Seasons (obliquity)** *(done)*`axialTilt` drives per-cell summer/winter temperatures
(`computeClimate`: `sTempSummer`/`sTempWinter` = annual mean ± a tilt/latitude/continentality
amplitude); winter temp feeds the Tundra/Taiga biome cutoffs (`biomeSeasonWeight`). Static
fields (the live yearly cycle is reserved for Live World). Color key `6` cycles the temp views.
> Durable design context (module layout, save format, climate/biome model, conventions)
> lives in **`docs/design-notes.md`** — important because Claude's auto-memory does not
> travel with the repo.
**Live World (in progress):** with planet creation finished, run the world at a slow real-time
scale with dynamic **weather** (clouds, rain, storms, fronts), day/night, and living
**ecosystems / civilization** evolving in real time. This is a separate large effort;
the fixed-grid Eulerian model + the climate fields are the groundwork for it.
- **Clock + day/night groundwork** *(done — see `PlanetLive.cpp`)* — a slow real-time clock
(hours → weeks/months, `liveTime`/`liveRate`, key `W` to enter once settled, `[`/`]` ramp
the rate), a moving **day/night terminator** (sun from time-of-day rotation + seasonal
declination via `axialTilt`; key `N`), a raylib-free per-cell **insolation field**
(`computeInsolation`, the weather hook), a **live seasonal temperature** cycling the
static summer/winter fields over the year (`computeLiveSeason`), and a **moving snow / sea-ice
line**. Day/night + snow are render overlays over any colour mode (3D + 2D). Save **v8** adds
the live-clock state. Still **future:** actual weather, day/night temperature swing,
precipitation/evaporation, living/evolving ecosystems.
- **Moons + tides + distant sun** *(done — see `PlanetOcean.cpp`)***13 moons** generated per
world from a separate RNG (no tectonic perturbation) and **saved (v9)**; they orbit on the live
clock and, with the sun, raise an **equilibrium tide** (`computeTides``sTide`, two bulges via
`cosθ²⅓`, semidiurnal). Tides show as a **tide-coloured coastline** (`buildCoastline` + a
diverging `tideColor`, key `T`, 3D + 2D). The 3D **sun** is now small + far with a faint halo;
**moons** render with sun-lit **phases** (offset-dark-sphere) + faint **orbit rings**, plus
**eclipses** — solar (a moon transiting the sun darkens a shadow spot in the day/night overlay),
lunar (a moon in the planet's shadow dims red). Knobs `tideAmplitude`/`tideSunFactor`.
- **Ocean currents (+ climate feedback)** *(done — see `PlanetOcean.cpp`)*`computeOceanCurrents()`
builds a per-ocean-cell tangent velocity from wind stress (`sWind`) + Coriolis deflection
(right N / left S) + coast-following (gyres) + smoothing. `computeClimate()` calls it and feeds
**warm (poleward) / cold (equatorward)** currents back into `sTemp` as a bounded coastal anomaly
(`climateCurrentFactor`), so biomes shift naturally. Rendered as warm/cold **current arrows**
over the sea (key `O`, 3D + 2D). This completes the Live World ocean/sky pass.
- **Weather — dynamic clouds & rain** *(done — see `PlanetWeather.cpp`)* — a per-cell
humidity/cloud/rain cycle advanced on the live clock: **evaporate** over warm sunlit seas →
**advect** humidity & cloud along the prevailing wind → **condense** into cloud (extra on
windward upslopes) → **rain** out → **dissipate**. Rendered as a translucent moving cloud
shell (white → dark storm where it rains) over the globe + 2D map (key `K`). Saved (v10).
- **Weather — moving systems (lows, hurricanes & typhoons)** *(done — see `PlanetWeather.cpp`)*
drifting low-pressure **agents** (`WeatherSystem`) spawn over warm tropical seas / mid-latitude
oceans, travel with the steering wind (poleward recurve), intensify over warm water, decay over
land, and **stamp** travelling cloud/rain onto the grid — so the sky visibly evolves. The intense
tropical ones are **hurricanes/typhoons** (spin by hemisphere, eye + animated spiral marker).
Saved (v11, so a load resumes active storms). This makes the weather visibly move (the base field
alone relaxes to a static pattern).
- **Volcanoes & volcanic islands** *(done — see `PlanetVolcano.cpp`)* — on entering Live World a
one-time pass (`placeVolcanoes`, separate RNG → tectonic determinism intact) seeds volcanoes by
tectonic context: **very high** probability on young spreading-ridge / "new-plate" cells (baby
plates), **medium** on normal plate borders, **low** elsewhere (hotspots). They are stateful
lifecycle agents: some start pre-built, growing vents can breach submarine cells into volcanic
islands, tall vents can go dormant, dormant vents explode and shave their peak, then puff ash while
regrowing weaker. Volcano state + `sVolRng` are captured in the Live World step-back snapshot, so
`,`/`.` reverses height, dormancy, explosions and ash timers. Rendered as growing, dormant and
post-explosion cone markers (3D + 2D, key `V`); saved (v15). Knobs `volcano*`.
**Civilizations (in progress — the long arc after the world is finished):** the eventual goal is
people who eat, name their world, found villages→cities, build kingdoms/empires, draw cultural +
geographic borders, and go to war. Built in phases (cell = territory, settlements = point agents, all
on the Live World clock). **Steps 12 of the roadmap are done (plus a derived ecoregions atlas):**
- **Geography & place-names (the atlas)** *(done — see `PlanetGeography.cpp` + `NameGen.cpp`)* — the
foundation everything civic references. `Planet::generateGeography()` extracts named features from
the (frozen) terrain by connectivity over the fixed grid — **continents/islands** (connected land),
**oceans** (the connected world ocean split into basins by a distance-from-land watershed → ~46
named oceans) **/ seas** (small water bodies), **lakes** (inland filled basins), **mountain ranges +
peaks** (connected high terrain), **rivers** (largest discharge mouths traced upstream via `flowTo`) — and
names each with a deterministic procedural namer (`NameGen`: syllable banks, a "language" per
continent so a region's places share a sound). A separate RNG (`sGeoRng`) keeps tectonic determinism
intact. Generated once on a settled world (key `M`, in or out of Live World), drawn as labels on the
globe + 2D map (minor features only when zoomed), listed in an **Atlas** tab (click a row to fly
there), and shown in cell-info as a "region" line. Per-cell feature-index arrays give O(1) "which
features is this cell in" (the hook for territory/borders later). Names dedupe on the proper-noun
root (no two features share a base name); a **new volcanic island** is named on the fly when it
breaches (the island-formation event carries its name). Saved (**v17**). Knobs `geo*`.
- **Ecoregions (ecological provinces)** *(done — see `PlanetEcoregions.cpp`)*`generateEcoregions()`
flood-fills cells sharing a biome + land/water context + productivity band into named ecological
provinces (dominant flora/fauna/funga archetype + flora/fauna/funga productivity per region), a
separate `sEcoRng`. Key `E` (colour mode `Ecoregion` + an **Eco** tab); saved (**v19**; v18 added a
geography reshuffle salt). Productivity feeds settlement habitability.
- **Settlements & habitability (Step 2)** *(done — see `PlanetCiv.cpp`)* — a derived per-cell
**habitability/food** score (`computeHabitability`: climate comfort + water access (rivers/lakes/
coast) + food (flora/fauna density + ecoregion productivity), gated by freezing winters / high
terrain; colour mode `Habitability`, key `I`). On key **`U`** ("the dawn") `placeSettlements()` seeds
a fixed set **once** on the best, well-spaced (`civMinSpacingRadians`) fertile cells (separate
`sCivRng`; named from the continent's `NameGen` bank). `stepCivilization(dtHours, liveTime)` runs each
live frame: population moves **logistically toward a food-driven carrying capacity**, but everything is
**environment-driven and dynamic** (not the old "grow the same everywhere"): the growth **rate** scales
with habitability (fertile cells boom, marginal crawl); the capacity `K = civMaxPopulation·habitability·
**siteQuality**·conditions` where **siteQuality** spreads max size by orders of magnitude (a great river
`discharge` is log-scaled — or a coast hosts a metropolis, a dry inland cell a town); and
**conditions** are deterministic time-varying drivers (pure functions of (region, year, seed) → constant
within a year, reversible on step-back): **regional droughts** (multi-year, worse in arid regions),
year-to-year **harvests** (bigger swings in continental interiors), rare **cold years** and river
**floods**, plus **volcano ash**. **Storms** over a town kill people directly (hurricanes worst,
`civStormDeathRate`/`civHurricaneDeathMult`); sustained famine / acute disasters can **collapse** a
settlement to ruins (kept in the set, can revive). Tiers village→town→city; markers (3D + 2D) sized by
tier and **withered-tinted** by hardship + a **Civ** tab + cell-info "conditions/drought" line + kind=3
`WorldEvent`s ("X grew into a city", "Hurricane <name> devastates X", "Famine shrinks X to a Town", "X
was abandoned"). The set is fixed, so the step-back snapshot only restores the per-settlement
**population** vector; conditions recompute. Saved (**v20**). Knobs `civ*`.
*Next steps (not yet built): territory + borders, kingdoms/empires, culture + beliefs, conflict +
diplomacy.*
## Current state
Working and verified (logic tested headless):
- Icosphere level 5 → 10242 cells, ~223 km/cell, topologically correct
(exactly 12 degree-5 vertices, rest degree-6).
- Plate flood-fill, drift as rotation on the sphere, boundary-stress uplift.
- Relief builds *gradually* toward an isostatic equilibrium (no clamp-rail
saturation): after ~40 ticks graded mountain belts (>2000 m) and deep
subduction trenches (<-6000 m), 0% of cells pinned to the clamp.
- `test_logic.cpp` asserts geometry, plate assignment, non-saturation and
graded relief; run it after any `Planet::step()` change (see below).
- raylib render: 3D globe (top) + 2D Equal Earth map (bottom strip), orbit
camera, 4 color modes. Phase 1 is a **generator**: it paces tectonic ticks
toward isostatic equilibrium (watchable, ~3 s), renders live, and
**auto-pauses** once the terrain settles (max per-tick change < 2 m). No
background thread that's Phase-2 (continuous drift/erosion) groundwork.
- **Phase 2 increment 1 (drift):** after forming settles, the app switches to
continuous **drift mode** on a My clock. Each plate has a real speed (1..20
cm/yr); `cflDtMy()` sets the timestep so the fastest plate advances ~half a
cell/step. `advect(dt)` moves plate membership + carried crust (plateId,
elevation, oceanic, geoAge) by an accumulation scheme: a boundary cell builds
`drift` (signed convergence distance) with its dominant other-plate neighbor;
+1 cell -> overrun (take that crust; but oceanic can't overrun a buoyant
continent -> it subducts under), -1 cell -> new young ridge crust (spreading).
Crust type now lives on the **cell** (`Cell.oceanic`), not the plate. The HUD
shows elapsed My; `[`/`]` set My/sec; auto-settle still gates Phase 1 -> 2.
- **Phase 2 increment 1.5 (plate lifecycle):** without this the count collapsed
to 1-3 and the world froze (a giant continental plate that can't shed cells).
Every `splitCheckEvery`(10) advect iterations (gated by `driftIter`) `advect()`
runs a Wilson-cycle lifecycle: **fission** (a plate over `splitFraction`(20%)
of cells splits along a random great circle through its centroid; prob ramps
`0.05+0.05*(pct-20)`, the new half gets a random drift — self-regulating, so
the count equilibrates ~8-12), **stalemate kick** (a plate whose size barely
changed over a window gets a new direction + speed boost to break deadlocks),
and **spreading plates** (rift cells become a `Plate.baby` young-ridge strip;
`coalesceBabyPlates()` merges connected blobs and dissolves tiny noise ones;
a strip past `babyPromoteFrac`(0.7%) is promoted to a real plate with random
drift + `volcanicLandFrac` of its interior turned to volcanic-island land).
The hard land clamp became a **soft band** (`landBand`) so volcanic land
persists; `acquirePlate()` reuses dead plate slots to keep `plates` bounded.
Stats panel separates real plates from "Young ridges: N strips"; plate-color
mode tints baby cells a uniform ridge grey. Verified by a headless soak.
- **Phase 2 increment 1.6 (calmer + cleaner plate map):** kicks were too twitchy
("strange border movement") so a plate must now stall for `stalemateWindows`(4)
consecutive check windows (`sStaleStreak`) before a kick. `deleteEnclosedPlates()`
absorbs any plate ringed by a single other plate into it (on a mutual pair only
the smaller). `fuseMiniPlates()` lets >= `fuseMinPlates`(3) clustered mini plates
(non-baby, < `miniPlateCells`) fuse into the largest member and steal one ring of
cells from their largest big neighbour (terrane amalgamation). Borders now draw
in two colors: real plate borders yellow, young spreading-ridge borders red
(3D + 2D, both via `B`). Soak: mini plates ~0, no enclosed slivers, ~8-12 plates.
- **Phase 2 increment 2 (erosion + sea level):** `Planet::erode(dtMy)` runs each
drift step after advect+step — a mass-conserving, slope-weighted downhill
sediment transport (one double-buffered gather pass): the higher cell of each
edge gives material to the lower, faster above sea level (`erosionLandRate`)
than below (`erosionSeaRate`). Highs wear down toward an uplift<->erosion
equilibrium; sediment fills basins and builds coastal shelves/deltas. No river
carving (sub-cell at 223 km -> subgrid later). `adjustSeaLevel()` (every
`seaLevelEvery` erode calls) eases `cfg.seaLevel` toward the percentile
elevation leaving `landFractionTarget` (30%) of cells above water — percentile
targeting (`nth_element`) because a proportional nudge oscillates across the
flat continental-base elevation. Two "land" notions coexist: crust type (plate
buoyancy) vs geographic (elev > seaLevel). Stats headline + water:land are now
geographic, with separate "Sea level" and "Crust %" lines. Headless: land -> 30%
all seeds, erosion conserves sum(elevation), deterministic.
- **Phase 2 increment 3 (gradual sea level + config/save files):** the sea-level
controller is now gradual — checked every `seaLevelEvery` (100) erode calls, it
nudges sea level by a fixed `seaLevelStep` (100 m) when outside a `seaLevelTol`
(2%) deadband, and only if the nudge reduces the error (so it rests near a flat
"cliff" instead of oscillating). A human-editable **`planet.cfg`** (key=value)
holds all PlanetConfig params (auto-created on first run, `F2` reloads +
regenerates); `loadConfig`/`saveConfig` share one `CONFIG_FIELDS` X-macro. A
**`planet.save`** binary holds seed + config + full planet state
(`Planet::writeState`/`readState`; geometry rebuilt from subdivisions via
`buildGeometry()`); `F5` saves, `F9` loads and resumes (deterministic
continuation, verified headless). File I/O lives in Planet (raylib-free).
- **Phase 2 polish (config validation, QoL, sim cleanups):** `validateConfig()`
range-checks every field (+ the cross-rule `oceanBase < continentBase`) on load
and `F2`; an invalid `planet.cfg` reverts to safe defaults without overwriting
it. Save bumped to **version 2** (now also persists the `[`/`]` drift rate;
version-gated reads accept older saves). Viewer adds a **crust-type color mode**
(`4`: continental warm brown / oceanic deep blue), `F` **fast-forward** (runs
`step()` to settled instantly), `F12` screenshot, `--seed`/`--config` CLI flags,
a `P<id>` label per plate on the drift arrows, and a min subdivision level of 1
(level 0 disallowed). Two sim refinements: `step()` re-anchors boundary (source)
cells to their original stress each dilation ring so adjacent belts don't
cross-inflate (sharper peaks; land flank cells 509->499); and the soft land-band
rift/accrete nudge now reads a frozen snapshot of pre-nudge `oceanic` so flipping
one cell can't cascade along vertex-index chains into linear "snakes". The
earlier drift-direction "inward" fix was reverted (it worsened snaking).
- **Phase 2 increment 4 (taller mountains + seafloor aging):** mountains used to
cap ~5000 m because collisions were under-weighted and `relax` snapped uplifted
crust back to `continentBase` once a migrating front passed. Now `step()` adds a
real **continent-continent collision** factor (`colliding[]` = continental cell
facing continental, `cfg.collisionFactor`) and an **Andes-class continental arc**
factor (`cfg.arcFactor`), and high continental crust gets **isostatic
persistence**: `relaxEff = relax*(1 - isostaticPersist*clamp((elev-continentBase)
/rootScale,0,1))`, so thick ranges stand and become *erosion-limited* (by the
drift-loop `erode()`) instead of relaxing away. These three boosts are **gated on
the `Planet::drifting` flag — active only in Phase-2 drift, OFF during Phase-1
forming** (which keeps the original mild factors + full relax). This is
deliberate: Phase-1 forming runs `step()` with no erosion, so if the strong
uplift + weak relax were active there it would never settle (uplift never
balanced) and would rail the clamp — gating to drift, where `erode()` runs every
tick, avoids both. main.cpp sets `planet.drifting=true` when forming settles (and
in `loadGame` from the saved phase), `false` on reseed/regen. **Seafloor aging->depth:**
oceanic crust subsides with `geoAge` via `oceanicBase(age) =
max(oceanBase, ridgeDepth - seafloorSubsidence*sqrt(age))` (half-space cooling);
`oceanBase` is now the **deep abyssal floor** (-6000 m), `ridgeDepth` the shallow
young value (-2500 m), and `seedInitialRelief()` seeds an oceanic age spread
(`seafloorSeedAge`) so the starting seafloor already has ridge->abyss variety.
Headless: ~half of seeds produce >7000 m ranges that persist (the rest are
legitimately low-relief ocean worlds), <2% pinned to the clamp on all seeds,
older seafloor markedly deeper, deterministic. Tune the new knobs in `planet.cfg`.
- **Phase 3 increment 1 (hydrology: rivers, lakes, fluvial erosion):**
`Planet::hydrology(dtMy)` = `routeFlow()` then mass-conserving fluvial erosion, on
the fixed grid (Eulerian, raylib-free). `routeFlow()` does priority-flood
depression-filling (epsilon tilt so flats drain; ocean cells are outlets)
`sFill`/`sLakeDepth` (a cell with `lakeDepth>0` above sea level is a **lake**),
steepest-descent over the filled surface `sFlowTo`, and flow accumulation in
descending-fill order `sDischarge` (rivers = `discharge>riverThreshold`). The
erosion pass walks the network upstreamdownstream carrying a sediment load:
stream-power incision `K*Q^m*S^n*dt` where under capacity, deposition where over
(`cap=riverTransport*Q*S`) filling lakes, building deltas at mouths, depositing
the remainder at ocean sinks so **sum(elevation) is conserved**. Lakes/rivers are
**derived from elevation each tick** (no new saved per-cell field; only erosion
writes back to `elevation`). Orchestration (main.cpp): after `phase3AfterMy`
drift-My the sim **pauses and prompts** ("Continue Phase 2" / "Start Phase 3");
`H` toggles Phase 3 manually. In Phase 3 the drift loop keeps running
(advect/step/erode) but at a **finer dt** (`cflDtMy()*phase3DtScale`) plus
`hydrology(dt)` drift never stops, just resolves finer. Lakes shade inland-water
blue (`recolor`); rivers draw as a `centroid→downstream` line network (3D + 2D,
two widths, `J` toggles). Save bumped to **v3** (+ a `phase3` header flag).
Headless: discharge grows downstream and all land rainfall reaches the sinks,
mass conserved to ~1e-15, ~10 lake systems + rivers persist, deterministic.
(Note: the hydrology phase is now framed in the UI as **Phase 2.5**; the
internal `phase3*` names are unchanged.)
- **Phase 3 increment 1 (biomes classify + color):** `Planet::classifyBiomes()`
(src/sim/PlanetBiomes.cpp, raylib-free) writes a per-cell `Cell.biome` (enum
`Biome`, 13 entries: Ocean, **Ice**, Lake, Beach, Wetland, Grassland, Savanna,
Desert, Forest, Taiga, Tundra, Hills, Mountains). A first **rule-based** pass with
no real climate yet: temperature = warm-equator curve (super-linear in latitude so
cold concentrates at the poles) minus an elevation lapse; moisture = latitudinal
rainfall belts (wet equator/mid-lat, dry subtropicsdeserts) + river discharge +
coastal proximity; classified first-match (iceoceanlakebeachmountainshills
lowland-by-temp/moisture). **Polar ice caps** fall out of the temperature test (it
also snow-caps high peaks). All thresholds are **tunable in `planet.cfg`** (the
`biome*` PlanetConfig fields `biomeIceTemp`, `biomeMountainElev`, the moisture
cutoffs, etc.; only the latitudinal rainfall-belt curve shape stays a fixed helper).
The biome is **saved per cell** save bumped to **v4** (per-cell biome byte appended
after `invader`; `readState(is, hasBiome)` reads it for v4, reclassifies for v3, so v3
saves remain loadable). Rendered as color mode `5` (`biomeColor`, src/render/Colors.cpp);
re-run each `refreshView`. `lakeColor` changed to bright turquoise so lakes read clearly
vs ocean (the "blue speckle" near a clicked cell is just the subgrid detail overlay,
where ±250 m value-noise dips below sea level near coasts not lakes). Headless:
every cell valid, both poles Ice, deep equatorial water Ocean, 4 land biomes present,
deterministic, v4 round-trips biome, v3 loads + reclassifies.
- **Phase 3 polish (smaller caps + axial tilt + grid labels):** ice caps trimmed a few
points (~17%→~14% of cells) by lowering `ICE_TEMP` in PlanetBiomes.cpp. Added a
planetary **`axialTilt`** (obliquity, default 23.44°, in PlanetConfig/`planet.cfg`):
the 3D globe + a drawn **spin-axis rod** (through the poles, red/blue pole caps) lean
by it via an `rlRotatef` about world Z wrapping all 3D content in `renderGlobe3D`;
picking un-rotates the world hit dir by tilt (`rotateZ`, src/render/Picking.cpp) and
3D plate labels rotate by +tilt so everything stays consistent (the picking sphere is
rotation-invariant). Biomes/2D map are unchanged (tilt is visual + groundwork for
seasons). The graticule (`G`) now shows **lat/lon degree numbers on the 2D map** edges
(`drawGraticuleLabels2D`, plain "60N"/"120W" the default font has no `°`). `axialTilt`
was added to PlanetConfig (`planet.cfg`). Headless: ice ~14%, biome + `axialTilt`
round-trip, deterministic.
- **Phase 3 polish (biome knobs in config + future-proof save):** the 17 biome
classification thresholds moved from constants into PlanetConfig `biome*` fields
(tunable in `planet.cfg`, `F2`). To stop config additions from breaking saves each
time, the save now stores config as a **self-describing key=value text block** (save
**v6**) parsed like `planet.cfg` (`writeConfigFields`/`parseConfigStream` shared);
doubles written at `precision(17)` round-trip exactly. Adding/removing config fields no
longer breaks saves; v6 just can't load pre-v6 saves (one-time break). Headless: cfg
(incl. non-default `biome*`) round-trips exactly, unknown/missing keys handled.
- **Phase 3 increment 2 (climate model):** `Planet::computeClimate()`
(src/sim/PlanetClimate.cpp, raylib-free, derived/not saved) builds two continuous
per-cell fields. **Temperature** `sTemp` C) = the latitude curve (`biome*` temp
params) elevation lapse. **Precipitation** `sPrecip`: prevailing winds are zonal by
band (tropics/polar easterly, mid-lat westerly); ocean cells are a moisture source and
each land cell takes its **upwind** neighbour's moisture, **rains out** more on windward
upslopes (orographic) and loses a multiplicative fraction per cell (continentality), so
leeward + deep-interior cells dry out. The raw field is near-binary (saturated where the
wind hits the sea, ~0 elsewhere), so it's **diffused** `climateMoistureSmooth` passes to
create wetdry transition zones, then normalized to `sMoist` (0..1, **median land
0.5**, robust to orographic spikes). `classifyBiomes()` now reads `sTemp`/`sMoist`
(dropping the old latitude+discharge+coast hack) rain-shadow/interior **deserts** + a
varied, per-world biome spread; wetlands now require adjacency to water (ocean/lake).
Color modes `6` (temperature, bluered) / `7` (precipitation, drywet). `computeClimate()`
runs before `classifyBiomes()` in `generate()` and `refreshView()`. New `climate*` config
knobs (planet.cfg). Headless: equator warm/poles cold, lapse, coastal wetter than interior,
deserts present, deterministic.
- **Seasons (obliquity):** `axialTilt` (previously visual-only) now drives a per-cell seasonal
temperature range. `computeClimate()` adds derived `sTempSummer`/`sTempWinter` (= annual mean
`sTemp` ± a half-amplitude `A = seasonAmpMax·tiltFactor·latShape·continentality`), where
`tiltFactor = sin(axialTilt)/sin(23.44°)` (0 tilt no seasons) and **continentality** comes
from a multi-source BFS ring-distance from ocean cells (coasts/oceans muted by thermal
inertia, interiors swing most). Big swings at high-latitude continental interiors, ~0 at the
equatorial coast. `classifyBiomes()` blends **winter** temp into the Tundra/Taiga cold cutoffs
via `biomeSeasonWeight` (0 = annual-mean-only/old behaviour, default 0.6) so cold-winter
interiors turn boreal/tundra (Siberia effect) the amplitude is geographically shaped, so
this expands cold biomes only where seasons bite. Derived/not-saved (no save bump). Color key
`6` now **cycles** meansummerwinterseasonality; cell-info shows summer/winter. New `season*`
+ `biomeSeasonWeight` config knobs. Headless: equator swing 1.6 °C vs 20 °C at high latitude,
interior land ocean, tilt=0 no seasons, higher tilt bigger swing, `biomeSeasonWeight=0`
leaves biomes unchanged, cold-biome count rises with seasons, deterministic.
- **UI polish (full cell info + view label + framing):** the cell-info panel
(`cellInfo`, src/render/Panels.cpp) now shows everything per cell crust type, **biome**
(`biomeName`), **temperature** + **precipitation %**, and **river/lake** when hydrology is
on in addition to the existing cell#, lat/lon, elevation, plate, geoAge. The active
color mode is shown **top-center** of the globe ("Biome view", etc., via `colorModeName`),
updating with `1``7`. The HUD title/status and the hydrology prompt were reworded to drop
the rigid "Phase N" labels (now "World Creation: forming / drift & erosion / hydrology");
internal `phase*` names are unchanged. Render/text only no sim/save/config change.
- **Biota (flora/fauna/funga):** the World-Creation stage after biomes. Two layers (src/sim,
raylib-free): (1) **density scalars** `sFloraDensity`/`sFaunaDensity`/`sFungaDensity` [0,1]
via `Planet::computeBiotaDensity()` flora = NPP Liebig-min of temp & moisture (0 on
water/Ice), fauna = herbivore capacity flora with carnivores gated on local prey
(`bioCarnPreyMin`), funga = flora-like but moisture/organic-matter-led + cold-tolerant.
Derived each tick (like climate), drive color modes `8`/`9`/`0`. (2) A discrete
**slot/point population** `Planet::generateBiota()` (key `L`, on a settled world) each land
cell draws broad **archetypes** from a comprehensive table (`biotaArchetypes()`, 49 entries
across Flora/Fauna/Funga incl. marine, each with Class/Order/Family/Size + a biome mask + climate
tolerance) into a per-kind slot cap + a density-scaled point budget (Tiny=1…Huge=5 cost),
weighted by suitability and a **regional bonus** for archetypes already placed in same-biome
neighbours (homogeneous regions, variety at boundaries). Organisms are labelled by their
**taxonomy** Family + Size + role (e.g. *Felidae (Big, Carnivore)*, with the full
*Class > Order > Family* tree in `organismTaxonomy()`), never an informal common name like
"big cat"; generalist families get a biome adjective (*Desert Muridae*). Uses a **separate
RNG** seeded from `cfg.seed` so generating biota never
perturbs tectonic determinism. Population is **saved** (`sBiota`, save **v7**); densities are
derived/not-saved. New files `PlanetBiota.{hpp,cpp}` + `PlanetFlora/Fauna/FungiGen.cpp`;
color modes `floraColor`/`faunaColor`/`fungaColor`; cell-info shows density % + the per-kind
organism list. `bio*` config knobs. Headless `test_biota.cpp`: density ranges/zeros, fauna
capacity, carnivore gating, slot/point budgets, determinism + RNG isolation, v7 round-trip,
pre-v7 loads empty. v7 reads v6-and-older (no biota block empty population; press `L`).
- **Biota marine flora & fauna (life in the ocean):** the biota layers used to be 0 on every
water cell (a hard `elevation<=sea` gate + no Ocean-masked archetypes), so the sea read as
barren. Now ocean cells (not under polar `Ice`) get a **marine primary productivity** in
`computeFloraDensity`: `base + (1-base)·max(shelf, coast)` where `shelf` = shallowness
(`1 - depth/bioMarineShelfDepth`, light to the photic floor) and `coast` = a multi-source BFS
**ring-distance from land** (nutrient runoff; mirrors the continentality BFS, seeded from land
not ocean) so productivity is rich on sunlit shelves/coasts, lower in the deep open ocean,
zero only under ice; `sMoist` (a *land* rainfall field) is **not** used at sea. `computeFaunaDensity`
now skips only `Ice` (was all water) so marine fauna = `flora·productivity`, with the existing
carnivore prey-gate clustering sharks/seals/squid on rich shelves. **Funga stays land-only.**
`generateBiota` populates ocean cells too (skip `Ice`; no marine funga) `fillFlora`/`fillFauna`
are unchanged because their biome-mask filter draws only the **new Ocean-masked archetypes**
appended to `biotaArchetypes()`: marine flora **Kelp / Seagrass / Phytoplankton** and marine fauna
**Forage fish / Reef fish / Shark / Baleen whale / Seal / Squid** (all `moistMin=0`, SST-zoned;
append-only so v7 saves are unaffected old saves just lack them until `L`). The flora/fauna
color views (`8`/`9`) render ocean on a **distinct marine ramp** (`marineFloraColor` deep
blueteal/green bloom, `marineFaunaColor` deep bluecyanwarm) so the sea still reads as sea;
land ramps + the funga view unchanged. New `bioMarineBase`/`bioMarineShelfDepth`/
`bioMarineCoastRings` config knobs (self-describing config **no save bump**). `test_biota.cpp`
updated: zero life under ice, marine flora/fauna present at sea + populate ocean tiles, funga 0
on water, capacity/carnivore-gate/budgets/determinism still hold.
- **Live World clock + day/night + live seasons + snow line:** the first **Live World** stage
(the slow real-time arc after World Creation). Engine (`src/sim/PlanetLive.cpp`, raylib-free,
derived/not-saved): `computeInsolation(dayOfYear01, timeOfDay01)` `sInsolation` (0..1 cosine
solar incidence; declination `axialTilt·sin(2π·doy)`, sub-solar longitude sweeps once per day;
the foundation the future weather sim reads) and `computeLiveSeason(doy)` `sLiveTemp` (the
annual-mean `sTemp` swung toward the existing `summerTemp`/`winterTemp` by the seasonal phase,
anti-phased across hemispheres). Viewer: key `W` (settled world) toggles **Live World** drift
freezes and `liveTime` advances at `liveRate` (sim hours/real-second), `[`/`]` ramp it
hourmonth; the HUD shows a `Year/Day/HH:MM` calendar (`dayLengthHours`/`yearLengthDays`).
`rebuildLiveOverlay()` builds a per-cell day/night brightness (`illum`, soft terminator, 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, a sun marker sits over the
lit hemisphere. Cell-info adds a `live temp / day-night / snow` line. Save **v8** appends the
Live World flag + `liveTime` (version-gated; older saves load with it off). New `PlanetLive.cpp`
in CMake + the headless list; `test_live.cpp`: insolation range, lit/dark hemispheres,
declination tracks `axialTilt` (polar day/night at solstice), live temp within the
summer/winter band + anti-phased, snow line advances in winter, determinism.
- **Live World moons, tides & a distant sun:** engine `src/sim/PlanetOcean.cpp` (raylib-free):
`generateMoons()` seeds **13 moons** (`Moon` struct in PlanetTypes) from a separate RNG
(`cfg.seed ^ 0x900D5EED`, tectonic stream untouched); `sunDirection`/`moonDirection`/
`moonOrbitNormal` give model-space sky geometry (one source of truth `computeInsolation` now
calls `sunDirection`). `computeTides(doy,tod,days)` `sTide` (m), equilibrium two-bulge tide
(`Σ w·(cosθ²⅓)`, moons + sun weighted `tideSunFactor`, scaled `tideAmplitude`); derived/not
saved. Viewer: `T` colours the **coastline** (`buildCoastline` dual-contour + `tideColor`
diverging ambercyan, per-segment in 3D + `drawColoredSegments2D` in 2D), auto-scaled to the
tide extent; `stepSim` computes tides + `moonDirs`/`moonNormals` each live frame. 3D render:
small **distant sun** (`sunDist`9) + halo; **moons** at a visible orbit band with sun-lit
**phase** (offset-dark-sphere), faint **orbit rings**, and **eclipses** solar shadow folded
into `rebuildLiveOverlay`'s `illum` near the sub-solar point, lunar dimming (reddish) when a
moon is in the planet's shadow. Cell-info adds a tide line; stats shows the moon count. **Save
v9** appends the moons block (`writeState`/`readState(...,hasMoons)`; pre-v9 saves synthesize
moons from the seed). `test_ocean.cpp`: moon count/determinism + RNG isolation, unit sweeping
sky dirs, zero-mean two-bulge tide (high under moon + antipode, low at 90°, moves with time),
save v9 round-trip. **Enclosed-sea cap:** `computeTides` flood-fills connected ocean bodies and
caps the amplitude of any body under 10 cells to `0.01·cells + 0.03` m (a one-cell sea 0.04 m,
an inland saltwater lake stays calm) a small closed basin can't build a real tidal range;
open oceans (≥10 cells) keep the full equilibrium tide.
- **Live World ocean currents + climate feedback:** `Planet::computeOceanCurrents()`
(PlanetOcean.cpp) builds a per-ocean-cell tangent velocity `sCurrent` (derived/not saved):
wind stress (`sWind`) rotated by a **Coriolis** deflection (right N / left S about the cell
normal), the across-shore component removed at land neighbours so flow **follows coasts**
(gyres), then 3 smoothing passes (re-projected to the tangent plane; zero on land).
`computeClimate()` calls it right after the wind pass and feeds it back: a **bounded coastal
temperature anomaly** = `climateCurrentFactor · (poleward speed / max)` on ocean cells (warm
poleward, cold equatorward), smoothed onto the coasts and added to `sTemp` **before** seasons,
so summer/winter + biomes shift with it. Render: `buildCurrents` emits subsampled warm/cold
**arrows** over the sea (warm = poleward/red, cold = equatorward/blue), key `O` (3D + 2D), built
in `refreshView`. New knob `climateCurrentFactor` (4 °C). `test_ocean.cpp` adds: currents
tangent + zero on land + widespread, feedback bounded by the knob and produces both warming and
cooling, deterministic. Live-World ocean/sky pass complete.
- **Live World dynamic weather (clouds & rain):** `Planet::stepWeather(dtHours)`
(PlanetWeather.cpp) advances a per-cell humidity/cloud/rain cycle on the live clock:
**evaporate** over warm sunlit ocean (uses `sInsolation`+`sTemp`), **advect** humidity & cloud
downwind (upwind differencing along `sWind`/`sUpwind`, `weatherWindKmh`), **condense** the
supersaturated air into cloud saturation `weatherSatBase + weatherSatTempCoef·T`, plus
windward **orographic** lift **rain** out cloud above `weatherRainThresh`, then **dissipate**.
`initWeather()` spins the fields up from the moisture climatology; bounded exponential rate
forms keep it stable at any timestep. Runs each live frame in `stepSim` (dt = the same sim-hours
added to `liveTime`; held when paused). Render: a translucent **cloud shell** (white dark
storm where it rains, alpha = cover) over the 3D globe + a `drawWeather2D` layer on the 2D map,
key `K` (default on); cell-info adds cloud/humidity/raining. **Saved v10** (humidity/cloud/rain,
flag-gated; older saves spin weather up live). Deterministic (no RNG). `test_weather.cpp`:
fields in range, clouds form + rain falls, oceans moister than land, determinism, v10 round-trip.
- **Live World moving weather systems (lows / hurricanes / typhoons):** the base cloud/rain
field relaxes to a *static* pattern under fixed forcing, so `stepWeather` now also runs a
population of drifting **`WeatherSystem`** agents (PlanetTypes; **saved v11**; separate
`sWeatherRng` seeded from `cfg.seed` tectonic determinism intact). Each step: **spawn** over
warm tropical ocean (525°, SST `weatherTropicalSST`) or a mid-latitude (3062°) ocean low
(capped at `weatherSystemMax`, prob `weatherSpawnRate`); **move** along the steering wind
(`sWind` at the nearest cell) + a poleward recurve at `weatherSystemSpeed`; **intensify** over
warm sea / **decay+cull** over land/cold; **stamp** a Gaussian cloud/rain shield
(`weatherSystemCloud`/`Rain`, scaled by strength × local humidity) so cloud clusters travel
and dissipate behind the system. A tropical system past `weatherHurricaneStr` is a
hurricane/typhoon. Render: an animated cyclonic **spiral marker** per system (red + eye for
cyclones, blue lows; spins with `liveTime`·hemisphere) in 3D + 2D, HUD system/cyclone counts,
and a storm list (basin-named) in the Live info `Weather` tab all under `K`. `test_weather.cpp`
adds: systems spawn, move between steps, thicken cloud, RNG isolation, determinism.
- **Live World viewer controls storm follow-cam, 2D map zoom, clock stepper:** (1) **`Y`** cycles
the 3D camera to **follow a storm** (by descending strength, off after the last). Tracked by a
stable `WeatherSystem.id` (assigned at spawn from `sStormNextId`; transient, not RNG); each frame
`handleInput` points the camera straight at it via `camYaw/camPitch` from `rotateZ(pos,+axialTilt)`
(modelworld), orbit-drag disabled while following, auto-release if it dissipates. (2) **2D map
zoom**: `mapZoom`/`mapPanX`/`mapPanY` + `Viewer::mapViewRect()` (mapRect scaled about its centre +
pan); every map projection call routes through it while the **scissor/frame stay `mapRect`**
(`drawMapTris` now derives y from the rect, not the fixed `m.pos`). Mouse-wheel over the map zooms
toward the cursor (18×); drag pans when zoomed, else rotates `mapLon`; 2D picking inverts the same
rect. (3) **Clock stepper**: the `stepSim` live body is factored into `Viewer::liveAdvance(dtClock,
dtWeather)`; **`.`** steps forward and **`,`** back by `liveRate` hours (both auto-pause, like a
video frame-step). Weather is an integrated path (not analytically reversible), so a forward step
snapshots the full state `Planet::captureWeather()`/`restoreWeather()` (humidity/cloud/rain/
storms/RNG) into a bounded `wxUndo` ring and **`,` restores the previous snapshot**, so the
step really reverses *everything* (clouds, rain, **moving storms**) plus the deterministic sky.
A manual step **always** records (rate-independent, via `wxPushSnapshot`); a continuous run records
a throttled snapshot (~1/sec) so a run is rewindable too. `,` searches the ring by time, the ring
drops oldest past `wxUndoMax`. The most recent `wxSaveMax`(40) frames are **persisted in the save
(v12)** so a load can rewind storms past the saved moment; a load also drops any stale pre-load
history. With no recorded past (e.g. immediately after a pre-v12 load) `,` rewinds the sky only and
says so. `S` in Live World aliases the forward step.
- **Live World event log:** `liveInfoRect` is a tabbed panel (`Sky`, `Tides`, `Weather`, `Events`).
The viewer-owned event journal is saved in v16, capped to the newest 200 entries, and records storm
formation/intensification plus volcano dormancy, eruptions and island breaches. Clicking an event
selects its cell, releases storm follow-cam, rotates the 3D view to it, and centres the 2D map at
the current zoom.
- Mouse hover (in either view) shows per-cell info. Clicking a tile opens a
right-side detail panel: tile info header + the tile's subgrid drawn as a
flat hoverable grid of subtiles (neighbor-owned subtiles dimmed). A high-res
subgrid patch is also overlaid on the globe for context; the hovered subtile
is marked on the globe. `C` closes the panel.
## Architecture
The code is split into a **raylib-free engine** (`src/sim`, testable headless)
and a **raylib viewer** (`src/render`); `src/main.cpp` is a ~10-line entry point.
`Planet` is one class implemented across several `.cpp` files (one per phase/
concern, all sharing `Planet.hpp`); the viewer is one `Viewer` struct whose state
+ methods are likewise spread across a few render files. CMake adds both folders
to the include path, so includes stay flat (`#include "Planet.hpp"`, `"Viewer.hpp"`).
```
src/
main.cpp entry point: build Viewer, init(argc,argv), run()
sim/ (raylib-free engine -- testable headless)
Vec3.hpp double-precision 3D vector math
IcoSphere.* geodesic icosphere: fixed vertices + neighbor adjacency
Projection.hpp Equal Earth equal-area projection (forward + Newton inverse),
dir<->lon/lat helpers. Header-only, raylib-free, testable.
PlanetTypes.hpp Cell / Plate / SubGrid / PlanetConfig data structures
Planet.hpp the Planet class declaration + config-file func decls
Planet.cpp generation, geometry, plate seeding, shared helpers, subgrid
PlanetTectonics.cpp step() (Phase 1/2 stress->uplift->relax)
PlanetDrift.cpp cflDtMy/advect + plate lifecycle (fission/kick/baby/fuse)
PlanetErosion.cpp erode() + adjustSeaLevel()
PlanetHydrology.cpp routeFlow/computeHydrology/hydrology (Phase 3)
PlanetClimate.cpp computeClimate() (temperature + orographic precipitation)
PlanetLive.cpp computeInsolation/computeLiveSeason (Live World: day/night + live seasons)
PlanetOcean.cpp moons (generate/orbit) + computeTides + computeOceanCurrents
PlanetWeather.cpp stepWeather (Live World dynamic clouds & rain cycle)
PlanetVolcano.cpp placeVolcanoes/stepVolcanoes (Live World volcanoes + islands)
PlanetBiomes.cpp classifyBiomes() (per-cell Cell.biome from elev + climate)
PlanetBiota.hpp BiotaKind/SizeClass/EcoRole/Organism/CellBiota + archetype table decls
PlanetBiota.cpp archetype library + slot/point draw + generateBiota/computeBiotaDensity
PlanetFloraGen.cpp computeFloraDensity + fillFlora
PlanetFaunaGen.cpp computeFaunaDensity + fillFauna (carnivores gated on prey)
PlanetFungiGen.cpp computeFungaDensity + fillFunga (moisture/organic-matter rule)
NameGen.* deterministic procedural name generator (syllable banks; reused by civ arc)
PlanetGeography.* generateGeography() (named features: continents/oceans/ranges/rivers/lakes)
PlanetEcoregions.* generateEcoregions() (named ecological provinces + dominant biota/productivity)
PlanetCiv.* computeHabitability/placeSettlements/stepCivilization (settlements; civ Step 2)
PlanetIO.cpp config file (text) + binary save/load
render/ (raylib viewer)
Colors.* cell color modes (elevation/plate/age/crust/biome/climate/biota)
Map2D.* Equal Earth 2D map: positions + projection/draw helpers
Overlays.* borders, drift arrows, rivers, graticule, segments, subgrids
Picking.* mouse ray / sphere hit / nearest-cell / angle helpers
Panels.* right-column UI: detail panel, hover info, world stats
Viewer.{hpp,cpp} Viewer struct: all state + setup + sim orchestration
ViewerInput.cpp handleInput(): camera, hover picking, click, keys
ViewerRender.cpp renderGlobe3D / renderMap2D / renderPanels / renderHUD / renderPrompt
CMakeLists.txt fetches raylib 5.5 via FetchContent; lists src/sim + src/render
BUILD.md dependencies + build/run + controls
```
### Key data structures (src/sim/PlanetTypes.hpp)
- `Cell` `unit` (fixed sphere direction), `elevation` (continuous meters,
double, NOT quantized), `plateId`, `geoAge`, `neighbors`, and a
`std::shared_ptr<SubGrid> subgrid` HOOK (still null on the cell; the viewer
builds subgrids on demand via `Planet::makeSubGrid(cell,res)`).
- `SubGrid`/`SubCell` a res*res patch around one macro cell; elevation is an
inverse-distance blend of that cell + neighbors plus value-noise detail, and
each subcell records `nearestMacro`. Phase-4 preview, generated on click.
- `Plate` `type` (Oceanic/Continental), `driftAxis`, `driftSpeed`.
- `PlanetConfig` `radius` (default 6.371e6 m, Earth), `subdivisions`,
`seaLevel`, `plateCount`, `seed`.
### Resolution notes (important, was discussed in design)
- **Lateral** resolution = cell spacing = `sqrt(4*pi*R^2 / N)`. Coarse on
purpose (~223 km at level 5). Only mountain-range scale, not single hills.
- **Vertical** (elevation) resolution is effectively unlimited: it is a
`double` in meters. 100 m steps or finer are free. Mount-Everest /
Mariana-Trench range is exactly representable.
- Climate (phase 3) reads elevation as a smooth continuous function, so no
resolution is lost by coarse height bands.
- For dense detail later (population/culture), generate a per-cell **subgrid**
on demand and mark which edge sub-cells border which neighbor macro-cell,
so cross-boundary interaction (rain shadow, transition zones) works.
## Build & run
```bash
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j
./build/planetsim
```
Target OS is Nobara Linux (KDE/Wayland, Intel Arc A770). Dependency install
line is in BUILD.md (`dnf install cmake gcc-c++ mesa-libGL-devel ...`).
raylib 5.5 is fetched automatically do not vendor it.
### Quick headless logic test (no display needed)
```bash
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/PlanetIO.cpp -o /tmp/t && /tmp/t
```
(Swap `test_logic.cpp` for `test_biota.cpp`, `test_live.cpp`, `test_ocean.cpp`,
`test_weather.cpp`, `test_volcano.cpp`, `test_geography.cpp`, `test_ecoregions.cpp` or `test_civ.cpp`
to run the Biota / Live World / Ocean / Weather / Volcano / Geography / Ecoregions / Civilization
suites same source list. CMake also builds `test_events` for the
viewer event journal.)
Use this to verify tectonics after changing `Planet::step()` without launching
the window (the engine lives in `src/sim` and is raylib-free, so it links without
any render code). The `#pragma omp` lines in `step()` are ignored without
`-fopenmp`, so this serial build is correct; add `-fopenmp` to benchmark the
threaded path.
### Performance / parallelism
`Planet::step()` is data-parallel: each pass writes only its own cell index
(double-buffered where it reads a field it writes), so the OpenMP `parallel for`
loops are **bit-identical for any thread count** (determinism intact). It is
memory-bandwidth-bound, so the speedup tops out ~3x (sub-7: ~5.1 -> ~1.5 ms)
around 4-8 threads regardless of core count. `step()` also reuses persistent
scratch buffers (the `s*` members) so it allocates nothing per tick. Small grids
stay serial via `if(n > 20000)`. Control threads with `OMP_NUM_THREADS` (4-8 is the sweet spot;
the default uses all cores for no extra gain). OpenMP is optional and
auto-detected by CMake. For a bigger leap (or 1M+ cells) the next step is a GPU
compute-shader port -- a Phase-2 effort.
## Controls
LMB drag orbit · wheel zoom · hover for cell info (3D or map) ·
click a tile to open its detail panel (subtiles) · `C` close panel ·
drag the 2D map to pan it east/west · `1`..`0` color by
elevation/plate/age/crust-type/biome/temperature/precipitation/flora/fauna/funga
(`8`/`9`/`0` = biota density; `6` **cycles** temperature → summer → winter → seasonality;
active mode shown top-center of the globe) ·
`B` plate borders · `D` drift vectors · `G` lat/lon grid · `J` rivers (Phase 3,
all in 3D + 2D) · `N` day/night terminator (Live World) · `T` tide-coloured coastline (Live World) ·
`O` ocean-current arrows (warm/cold) · `K` weather clouds/rain (Live World) ·
`V` volcano markers (Live World) · `M` place-name labels (the atlas; names the world on first use) ·
`E` ecoregions colour view (names ecology on first use) · `I` habitability heat map ·
`U` settlements (the dawn of civilization on first press; toggles markers after) ·
`SPACE` or on-screen button pause ·
`[`/`]` drift speed (My/sec) — in **Live World** the live-clock rate (hours/sec, hour→month) ·
`S` single tick (in **Live World** steps the clock forward) · `.`/`,` step the live clock
forward/back by one rate-unit (auto-pauses; `,` steps **everything** back incl. weather/storms via
an undo history) ·
`Y` cycle the 3D camera to **follow a storm** (off after the last) · mouse-wheel **over the 2D map**
zooms toward the cursor (drag pans when zoomed) · `F` fast-forward Phase-1 forming to settled ·
`H` toggle Phase 3 (hydrology) · `L` generate biota population (flora/fauna/funga,
on a settled world; re-press regenerates) · `W` enter/leave **Live World** (settled world) ·
`R` reseed ·
`+`/`-` subdivision level (1..7) · `F5` save (`planet.save`) · `F9` load ·
`F12` screenshot (`screenshot.png`) · `F2` reload `planet.cfg` + regenerate.
Phase 3: after `phase3AfterMy` simulated years a modal prompt asks **Continue
Phase 2** / **Start Phase 3**; `H` starts/stops it manually. In Phase 3 drift
keeps running at a finer timestep (`cflDtMy()*phase3DtScale`) while rivers, lakes
and fluvial erosion evolve.
Live World (`W`, on a settled world): geological drift freezes and a slow real-time clock runs
(`liveTime` in hours, `liveRate` = sim hours/real-second, ramped hour→month with `[`/`]`). A
moving day/night terminator (`N`), a live seasonal temperature cycle and a moving snow/sea-ice
line animate over whatever colour mode is active; the HUD shows a `Year/Day/HH:MM` calendar.
**13 moons** orbit (sun-lit phases, orbit rings, solar/lunar eclipses) and, with the distant
sun, raise tides — `T` colours the coastline by the live tide level (amber low ↔ cyan high).
`K` shows moving weather (clouds, rain, drifting storms / hurricanes). **Volcanoes** are placed by
tectonic context on entry and run a stateful lifecycle — submarine ones can build into new
**volcanic islands**; `V` toggles the cone/eruption markers. `Y` makes the 3D camera **follow a storm** (cycles by strength,
off after the last); `.`/`,` step the clock forward/back by one rate-unit (back rewinds the sky **and**
weather/storms/volcano lifecycle via snapshots). Mouse-wheel over the 2D map zooms (drag pans).
CLI: `--seed N` overrides `cfg.seed`; `--config PATH` uses an alternate config
file (both applied before the initial load/generate).
Two files in the working dir: `planet.cfg` (human-editable key=value of every
PlanetConfig param, auto-created on first run, reload with `F2`) and
`planet.save` (binary: seed + config + full planet state, written/read by
`Planet::writeState`/`readState`, resumes deterministically). Config is
range-checked by `validateConfig()` on load/`F2`; an invalid file reverts to safe
defaults (without overwriting your `planet.cfg`) and shows a status message. The
save header is versioned (currently **20**; v2 adds the `[`/`]` drift rate, v3 a
`phase3` flag, v4 a per-cell biome byte, v6 stores config as a **self-describing
key=value text block** instead of a raw POD dump, v7 appends the **biota population**
block — three Organism lists per cell, gated by a flag byte, v8 appends the **Live World**
clock — a flag byte + `liveTime`, v9 appends the **moons** block, v10 appends the **weather** block —
humidity/cloud/rain, flag-gated, v11 also persists the **weather systems** + RNG so a load resumes
active storms, v12 appends the most recent **step-back frames**`wxSaveMax`(40) weather snapshots
— so a load can rewind storms past the saved moment, v13 appends the Live World clock rate, v14
appends the old pure-function **volcanoes** block, v15 replaces it with stateful volcano lifecycle
agents plus volcano state in step-back frames, v16 appends the saved **event journal**, v17
appends the **geography/atlas** block — named features + per-cell region indices, v18 a geography
reshuffle salt, v19 the **ecoregions** block, and v20 the **civilization settlements** block (the
fixed settlement set + per-frame populations in the step-back history);
newer-than-supported is
rejected. Older saves (no biota block) load fine with an empty population (press `L`);
pre-v8 saves load with Live World off; pre-v9 saves synthesize moons from the seed; pre-v10
saves spin weather up live; pre-v11 saves load with no active storms (they respawn); pre-v12 saves
load with no step-back history (you can still step forward then back); pre-v13 saves resume with
the default live clock rate; pre-v14 saves load with no volcanoes (placed on the next Live World
entry); v14 volcanoes are discarded and reseeded as v15 lifecycle agents, with old history skipped;
pre-v16 saves load with an empty event journal; pre-v17 saves load with no geography (regenerated on
demand via `M`); pre-v19 saves load with no ecoregions (regenerated via `E`); pre-v20 saves load with
no settlements (re-seeded via `U`).
A load drops any **stale** pre-load `wxUndo` history and reloads the
saved one.
**As of v6, adding/removing PlanetConfig fields no longer breaks saves** — the saved
config is parsed like `planet.cfg` (unknown keys ignored, missing keys keep defaults),
written at `precision(17)` so doubles round-trip exactly. (v6 cannot load pre-v6 saves —
a one-time break; a length guard makes that fail gracefully.) `writeConfigFields`/
`parseConfigStream` in PlanetIO.cpp are shared by `planet.cfg` and the save.
Layout (1920x1080): left column 70% wide = 3D globe (top, 60% h, RenderTexture
1344x648) + 2D Equal Earth map (bottom, 40% h, **left-aligned**, with the freed space at
its right holding the Live-World tabbed info panel — `liveInfoRect`, `renderLiveInfo`:
Sky moon phase discs, selected coastal-tile tide phase, active weather systems, and the clickable
saved event journal); right column 30% wide = cell
info (top 50% h) + subareas (bottom 50% h). 3D hover uses a custom camera ray with the
3D viewport (1344x648 at the origin -- GetScreenToWorldRay assumes the full
screen, wrong here); 2D hover uses EqualEarth::inverse (minus the `mapLon` pan).
Borders (`B`), drift vectors (`D`) and a lat/lon graticule (`G`) draw in BOTH the
3D globe and the 2D map; drag the 2D map left/right to pan longitude (`mapLon`).
The graticule (`G`) also draws **lat/lon degree numbers along the 2D map edges**. The
3D globe (and its spin-axis rod) lean by `cfg.axialTilt` (an `rlRotatef` about world Z
around all 3D content in `renderGlobe3D`); 3D picking un-rotates the world hit dir by
tilt and plate labels rotate by +tilt (`rotateZ`, src/render/Picking.cpp) to stay in sync.
Borders draw in two colors: real plate boundaries yellow, young spreading-ridge
(baby-plate) boundaries red — both via `buildBorders` filling two segment lists.
Drift arrows: one cyan arrow per plate at its centroid along the local drift
velocity `omega x r`; length scales with speed. Rebuilt per world-gen. Each plate
is also tagged with a `P<id>` label drawn just above its centroid — projected by
hand in 3D (the project's own `Vec3` camera-basis math, matching `BeginMode3D`'s
fovy/aspect, behind-camera points skipped) and via Equal Earth in the 2D map.
**Sim model (`Viewer::stepSim`, src/render/Viewer.cpp):** single-threaded. Each
frame (while not paused/settled)
it advances ~`formRate` (55) ticks/second of `planet.step()` (Phase 1 = pure
tectonic forming, no erosion — `planet.drifting` is false) and rebuilds the view
live, so you watch the terrain rise. `step()` returns the max per-tick
elevation change; after `settleNeed` (3) consecutive ticks below `settleThresh`
(2 m) it sets `settled` and stops stepping — no idle CPU. Re-evolve / reseed /
subdivision-change clear `settled` and restart the forming pass. Rendering reads
the live `cells` directly (safe: nothing else mutates them). Phase 2's
*continuous* sim will want a worker thread + a render snapshot (so the 60 fps
render never races the sim) — that's the natural place to reintroduce threading.
Plate borders are traced once per world-gen as a dual contour through boundary
triangles (plates are fixed in phase 1).
## Tuning knobs (expect to adjust these first)
- `elevExagg` (src/render/Viewer.hpp) — visual elevation exaggeration; without it the
sphere looks smooth.
- `axialTilt` (PlanetConfig, `planet.cfg`) — obliquity in degrees (default 23.44). Leans
the 3D globe + spin-axis rod; groundwork for seasons. Editable + `F2` to apply.
- Biome thresholds (`biome*` in PlanetConfig / `planet.cfg`) — `biomeIceTemp` (lower =
smaller polar/snow caps), `biomeMountainElev`/`biomeHillsElev`, the moisture cutoffs
(`biomeDesertMoist`/`biomeGrassMoist`/`biomeWetlandMoist`), and the temperature model
(`biomeEquatorTemp`/`biomePoleDrop`/`biomeLatExp`/`biomeElevLapse`, shared with climate).
Editable + `F2`.
- Climate (`climate*` in PlanetConfig / `planet.cfg`) — precipitation model:
`climateOrographic` (windward-rain strength), `climateRainEfficiency`,
`climateContinentality` (inland drying), `climateMoistureSmooth` (diffusion passes →
wet/dry transition zones; raise for smoother, more grassland/forest), `climateOceanMoisture`,
`climateOroRefHeight`, `climateWindPasses`. Temperature uses the `biome*` temp params.
`climateCurrentFactor` (4 °C) is the max coastal warming/cooling from ocean currents (0 = off;
ocean-current arrows toggle with `O`). Current deflection angle + smoothing passes are
constants in `computeOceanCurrents()` (PlanetOcean.cpp), not config.
- **Weather (`weather*` in PlanetConfig / `planet.cfg`):** the Live World clouds/rain cycle —
`weatherEvapRate` (ocean evaporation speed), `weatherWindKmh` (advection speed of humidity/cloud),
`weatherSatBase`/`weatherSatTempCoef` (how much moisture the air holds vs temperature — lower
base = cloudier), `weatherCondense` (supersaturation→cloud rate), `weatherOrographic` (windward
rain on mountains), `weatherRainThresh`/`weatherRainRate` (when/how fast thick cloud rains),
`weatherCloudDissip` (cloud clearing). Toggle the overlay with `K`. Cloud render colours
(white→storm, alpha) are constants in ViewerRender/Map2D.
- **Weather systems (`weather*` storm knobs, `planet.cfg`):** `weatherSystemMax` (concurrent
cap), `weatherSpawnRate` (genesis frequency), `weatherSystemSpeed` (km/h drift),
`weatherTropicalSST` (min SST for tropical genesis), `weatherSystemRadius` (cloud-shield size),
`weatherSystemCloud`/`weatherSystemRain` (stamp strength), `weatherHurricaneStr` (strength to
count as a hurricane/typhoon). Markers show under `K`. Tune these for a stormier or calmer world.
- Seasons (`season*` + `axialTilt` + `biomeSeasonWeight`, `planet.cfg`) — `axialTilt` is the
master driver (0 = no seasons); `seasonAmpMax` (18 °C max seasonal half-range at full
tilt/lat/interior), `seasonLatExp` (1.2, push swing toward poles), `seasonContinentRings`
(6, ocean-distance to full continentality; lower = coasts go continental sooner),
`seasonOceanFactor` (0.15, ocean/coast swing floor). `biomeSeasonWeight` (0.6) sets how much
winter temp drives the Tundra/Taiga cutoffs (0 = annual-mean only, restores pre-seasons biomes).
- Biota (`bio*` in PlanetConfig / `planet.cfg`) — density: `bioVegTempMin`/`bioVegTempOpt`/
`bioVegMoistRef` (flora temp/moisture limits), `bioFaunaProductivity` (animals per unit
flora), `bioCarnPreyMin`/`bioCarnScale` (carnivore prey gate + ramp), `bioFungaMoistRef`/
`bioFungaFloraWeight`/`bioFungaTempMin` (funga moisture/organic-matter/cold rules); **marine
flora/fauna**: `bioMarineBase` (0.15, open-ocean baseline density far from land), `bioMarineShelfDepth`
(2500 m, depth over which shelf/light productivity fades to the base), `bioMarineCoastRings`
(3, ocean rings from land over which coastal-nutrient richness fades to the base — lower = a
tighter coastal band, higher = life further offshore);
slot/point population: `bioFloraSlots`/`bioFaunaSlots`/`bioFungaSlots` (distinct-type cap),
`bioFloraPoints`/`bioFaunaPoints`/`bioFungaPoints` (point budget at full density, scaled by
it; Tiny=1…Huge=5), `bioRegionBonus` (how strongly a cell copies same-biome neighbours →
homogeneity vs variety). To add organisms (incl. marine — give them a `B::Ocean` biome mask
and `moistMin=0`), append to `biotaArchetypes()` in PlanetBiota.cpp (append-only — indices are
serialized in v7 saves).
- **Live World (`dayLengthHours`/`yearLengthDays`/`snowTemp`/`seaIceTemp`, `planet.cfg`):**
`dayLengthHours` (24) sets the day/night period (and the `d/s` rate unit), `yearLengthDays`
(365.25) the season period; `axialTilt` drives the seasonal declination (0 = no day/night
tilt). `snowTemp` (0 °C) is the land snow line, `seaIceTemp` (-2 °C) the ocean sea-ice line —
raise either to grow the white caps. Render constants (night-floor brightness, terminator
softness, snow-blend ramp) live in `rebuildLiveOverlay()` (src/render/Viewer.cpp), not config.
- **Moons & tides (`tideAmplitude`/`tideSunFactor`, `planet.cfg`):** `tideAmplitude` (0.6 m)
scales the equilibrium tide per unit tide-raising weight (raise for a more dramatic coastline
swing); `tideSunFactor` (0.46) is the sun's tide weight vs a unit moon. Moon count (13) and
per-moon orbit/period/inclination/mass are randomized in `generateMoons()` (PlanetOcean.cpp);
the 3D sun distance/size, moon orbit-render band and eclipse angles are render constants
(ViewerRender.cpp / `rebuildLiveOverlay`), not config.
- **Volcanoes (`volcano*` in PlanetConfig / `planet.cfg`):** placement by tectonic context —
`volcanoProbRidge` (0.55), `volcanoProbBorder` (0.06), `volcanoProbInterior` (0.003) are the
per-cell placement probabilities for young-ridge / plate-border / interior cells (raise for more
vents of that kind), `volcanoMaxCount` (60) caps the total (reservoir-sampled so the ratios hold).
Lifecycle — `volcanoInitialBuildMax` (2500 m) pre-builds some vents on entry, `volcanoBuildRate`
(0.02 m/h at activity 1) grows active vents, `volcanoFreeHeight` (1000 m absolute) protects deep
vents from dormancy, and `volcanoMaxHeight` (3200 m built) is the soft height where dormancy becomes
certain. Dormancy/explosions — `volcanoDormancyRate` (1/year scale),
`volcanoDormantMinYears`/`MaxYears` (120/1200), `volcanoExplodeDropFrac` (0.20),
`volcanoActivityDecay` (0.70), and `volcanoDeadActivity` (0.05). Ash FX —
`volcanoBlastRadius` (0.09 rad), `volcanoBlastCloud` (1.5), `volcanoAshMinYears`/`MaxYears`
(0.5/3), `volcanoAshPuffCellsPerWeek` (2), `volcanoAshCloud` (0.9), and
`volcanoAshCooling` (6 °C). Marker sizes/colours are render constants (ViewerRender.cpp).
- **Geography / atlas (`geo*` in PlanetConfig / `planet.cfg`):** feature-extraction thresholds —
`geoContinentMinCells` (40, land component ≥ this = Continent, else Island), `geoSeaMaxCells`
(60, ocean **basin** ≤ this = Sea, else Ocean), `geoMountainElev` (2500 m, min elevation for a
mountain-range cell), `geoRangeMinCells` (4, min cells for a named range), `geoRiverMinDischarge`
(80, min mouth discharge for a named river), and the label-clutter caps `geoMaxRivers` (40) /
`geoMaxPeaks` (40, largest/highest kept). **Ocean basins** — the connected world ocean is split
into several named oceans by a distance-from-land watershed: `geoOceanSepRadians` (1.40 rad, min
angular gap between basin centres — *raise → fewer oceans*, lower → more) and `geoOceanDeep` (4, min
rings from land for a cell to seed a basin); defaults give ~46 oceans on an Earth-like world, 1 on a
waterworld. Feature names are deduped on the **proper-noun root** (no shared roots across kinds), and
a new volcanic island is named on the fly (`Planet::nameNewLand`, joins an adjacent landmass or mints
a fresh Island). Name flavour (syllable banks, a "language" per continent) + label fonts/colours are
constants in NameGen.cpp / ViewerRender.cpp, not config.
- **Civilization / settlements (`civ*` in PlanetConfig / `planet.cfg`):** placement — `civMaxSettlements`
(80, cap), `civMinSpacingRadians` (0.10 rad ≈ 640 km, min gap between sites), `civMinHabitability`
(0.22, don't place below this). Habitability blend — `civHabWaterWeight` (0.45), `civHabFoodWeight`
(0.40, the rest is temperature comfort), `civHabTempOpt` (18 °C, most comfortable mean), `civHabElevPenalty`
(2500 m, high terrain steeply penalised above this). Population — `civSeedPopulation` (250, initial
village), `civGrowthRate` (0.02/yr logistic rate), `civMaxPopulation` (2e6, the carrying capacity at
habitability 1), tier thresholds `civTownPop` (5000) / `civCityPop` (100000), `civAbandonPop` (50,
below = abandoned/ruins but can revive). **Dynamics**`civSiteVariety` (1.0; 0 = flat capacities,
higher = big rivers/coasts host far larger cities → wide size spread), `civGrowthMin` (0.25, growth-rate
floor at habitability 0), `civHarvestVar` (0.25, year-to-year harvest swing, scaled by continentality),
`civDroughtStrength` (0.70) / `civDroughtPeriod` (8 yr) / `civDroughtThresh` (0.15) / `civDroughtArid`
(0.50, drought-proneness in arid regions), `civColdYearStrength` (0.50), `civFloodBonus` (0.25, river
silt), `civFamineRate` (0.15, accelerated loss when food < population), `civStormDeathRate` (0.50) /
`civHurricaneDeathMult` (3.0, deaths from a storm/hurricane over a town). Droughts/harvests are
deterministic per (~20° region, year, seed); an active volcano's ash within ~1.5× its blast radius also
cuts capacity. Marker sizes/colours + hardship tint are render constants (ViewerRender.cpp).
- `upliftGain` (PlanetConfig) m/tick per unit convergence stress; main
knob for how fast/high relief builds.
- `relax` (PlanetConfig) isostatic relaxation toward base elevation. Peaks
asymptote at `base + perTickUplift/relax`, so raising it lowers/flattens
the equilibrium; lowering it makes relief taller and slower to settle.
- `beltWidth` (PlanetConfig) how many cell-rings a mountain belt spreads
inland (belt width / flank extent).
- `trenchFactor` (Planet::step) depth multiplier for subduction trenches.
- `driftSpeed` (Planet::assignPlates) how fast boundary stress builds.
- **Plate lifecycle (PlanetConfig, Phase 2 inc. 1.5):** `splitFraction` (0.20,
plate share that may rift), `splitProbBase`/`splitProbSlope` (fission prob
ramp), `splitCheckEvery` (10, iters between periodic checks), `stalemateEps` /
`stalemateBoost` (deadlock detection + kick), `stalemateWindows` (4, consecutive
stuck windows before a kick raise to calm border jitter), `babyMinCells` (4,
baby blobs smaller than this dissolve as noise), `babyPromoteFrac` (0.7%,
ridge-strip size to become a real plate), `volcanicLandFrac` / `volcanicElev`
(land grown on promotion), `landBand` (0.10, soft land-conservation band),
`miniPlateCells` (50, below = "mini") + `fuseMinPlates` (3, cluster size to fuse
+ steal). Tune these to control how lively / fragmented the plate map stays.
- **Erosion + sea level (PlanetConfig, Phase 2 inc. 2):** `erosionLandRate` (0.08)
/ `erosionSeaRate` (0.02) fraction/My worn off above / below sea level (raise
for faster, flatter terrain); `landFractionTarget` (0.30) geographic land
goal; `seaLevelStep` (100 m) fixed nudge per adjustment + `seaLevelTol` (0.02)
deadband where sea level rests (raise step or shrink tol for a tighter 30/70,
but a big step can overshoot a flat "cliff"); `seaLevelEvery` (100) iterations
between sea-level updates (higher = more gradual). All editable in `planet.cfg`.
- **Orogeny taller mountains (PlanetConfig, Phase 2 inc. 4):** `collisionFactor`
(1.8, continent-continent uplift, Himalaya) and `arcFactor` (1.4, continental
subduction-arc uplift, Andes) raise either to make ranges taller/more reliable
across seeds; `isostaticPersist` (0.85, how strongly high crust resists relax
toward 1 = ranges barely erode and continents stay high; lower = more dynamic
rise/erode) saturating at `rootScale` (2500 m above `continentBase`). These three
boosts are **drift-only** (gated on `Planet::drifting`); in Phase-2 drift the
per-tick `erode()` is what limits their height, so they don't rail the clamp.
- **Soft peak cap (drift-only) no 9000 m plateau:** previously the hard
`[-11000, 9000]` clamp (in `step()`, `erode()` *and* `hydrology()`) flattened many
drift-time peaks into a 9000 m plateau. Now growth is **probabilistic** above
`peakSoftCapStart` (7000 m): the chance a tick's positive uplift "takes" falls
linearly to 0 at `peakSoftCapEnd` (12000 m), so peaks spread smoothly across a
height band instead of railing at one ceiling. A **lost** grow roll forfeits that
tick's uplift *and* shaves a random `0..peakFailDrop` (200 m) off, so a peak hovers
near its own height (height now tracks orogeny strength: strong seeds reach
1011 km, most cluster lower). The hard clamp's **upper bound now tracks
`peakSoftCapEnd`** in all three places (just a safety rail; lower 11000 m
unchanged). The roll is a **pure hash of `(cellIndex, erodeIter, seed)`** never
touches `rngState`, stays bit-identical across OpenMP thread counts, and since
`erodeIter` is saved (step/erode run 1:1 in drift) F5/F9 resumes **bit-identical**
(no save-version bump). **Drift-only** (gated on `drifting`) so Phase-1 forming
still auto-settles. Tune `peakSoftCapStart` / `peakSoftCapEnd` / `peakFailDrop` in
`planet.cfg`. Verified headless: smooth 8.510.5 km taper, 0 cells pinned at the
ceiling, determinism + exact resume intact.
- **Seafloor aging->depth (PlanetConfig, Phase 2 inc. 4):** `oceanBase` is now the
**deep abyssal floor** (-6000 m, not a flat ocean base) and `ridgeDepth` the
shallow young value (-2500 m); `seafloorSubsidence` (280 m per sqrt(My)) sets how
fast oceanic crust deepens with `geoAge` (half-space cooling), and
`seafloorSeedAge` (80 My) is the initial oceanic age spread at generation so the
starting seafloor already has ridge->abyss variety.
- **Hydrology (PlanetConfig, Phase 3 inc. 1):** `phase3AfterMy` (300) — drift-My
before the Phase-3 prompt; `phase3DtScale` (0.2) — Phase-3 timestep =
`cflDtMy()*this` (smaller = finer carving, slower drift per step); `rainfall`
(1.0) — uniform precip per cell (drainage-area unit; orographic precip is a later
climate add-on); `riverThreshold` (25) — discharge above which a cell is a river
(also the river-render threshold); `riverIncision` K (0.02) + `riverDischargeExp`
m (0.5) + `riverSlopeExp` n (1.0) — stream-power incision `K*Q^m*S^n*dt` (raise K
for faster valley carving); `riverTransport` (0.1) — transport capacity
`cap=this*Q*S` and `depFrac` (0.25) — deposition rate of excess load (raise both
for more deltas / faster lake infill). All editable in `planet.cfg`.
## Conventions
- All code, identifiers, comments and filenames in **English**.
- Before generating code, summarize the approach and ask whether to proceed.
- Prefer **inline code in chat** over attachments; give exact file contents
rather than long explanations.
- Direct, concrete answers. Metric system throughout.
- C++17, multi-file CMake. Keep simulation logic free of raylib so it stays
testable headless: engine in `src/sim` (no raylib), all rendering in `src/render`.
- Geometry stays fixed — never make cells move; add new per-cell properties
and flow them over the existing grid + neighbor adjacency.