planetsim/docs/design-notes.md
Jonas Reith f84e06507a Live World weather: dynamic clouds & rain cycle (save v10)
A per-cell humidity/cloud/rain cycle advanced on the live clock (PlanetWeather.cpp,
raylib-free): evaporate over warm sunlit seas -> advect humidity & cloud along the prevailing
wind (upwind differencing) -> condense into cloud (saturation vs temperature + windward
orographic lift) -> rain out thick cloud -> dissipate. Bounded exp-rate forms keep it stable at
any timestep, so it runs cleanly from hours/sec up to a month/sec. initWeather() spins the
fields up from the moisture climatology; fully deterministic (no RNG).

Render: a translucent cloud shell over the 3D globe (white -> dark slate where it rains,
alpha = cover) plus a matching drawWeather2D layer on the 2D map (shared drawMapTris
rasterizer), toggled with K (default on). stepSim runs stepWeather each live frame at the
sim-hours added to liveTime (held when paused). Cell-info shows cloud/humidity/raining.

Saved as v10 (humidity/cloud/rain, flag-gated; pre-v10 saves spin weather up live). New
weather* config knobs. Reseed/regen now also drops out of Live World. test_weather.cpp:
fields in range, clouds form + rain falls, oceans moister than land, determinism, v10
round-trip; the other four suites still pass; GUI build clean. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:01:34 +02:00

15 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).
  • 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; 0 on water/Ice. (2) On-demand discrete population generateBiota(): each land 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).

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). Future: tropical cyclones (moving vortex agents) layered on this field.

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/PlanetBiota.cpp src/sim/PlanetFloraGen.cpp src/sim/PlanetFaunaGen.cpp \
    src/sim/PlanetFungiGen.cpp src/sim/PlanetIO.cpp -o /tmp/t && /tmp/t
# test_biota.cpp uses the same source list (Biota suite).

(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).