planetsim/docs/design-notes.md
Jonas Reith 53e371bb2f Add Biota stage: flora, fauna & funga (density + slot/point population)
World-Creation stage after biomes. Two layers (raylib-free engine):
- Per-cell density scalars (flora/fauna/funga in [0,1]) derived from the
  climate fields each tick (drive color modes 8/9/0). Flora = Liebig-min of
  temp & moisture; fauna ~ flora with carnivores gated on local prey; funga =
  moisture/organic-matter-led + cold-tolerant. Zero on water/ice.
- On-demand discrete population (key L, saved as v7): each land cell draws
  broad archetypes from a comprehensive table into a per-kind slot cap +
  density-scaled point budget (size -> cost), weighted by biome/climate
  suitability and a regional bonus for same-biome neighbours. Separate RNG
  seeded from cfg.seed so generating biota never perturbs tectonic determinism.

Organisms are labelled by taxonomy (Family + Size + role, e.g. "Felidae
(Big, Carnivore)") with the full Class > Order > Family tree stored, never an
informal common name. Cell-info panel word-wraps + aggregates duplicates so the
lists no longer get cut off.

New: src/sim/PlanetBiota.{hpp,cpp} + PlanetFlora/Fauna/FungiGen.cpp, color
modes/colors, bio* config knobs, save v7 (older saves load with empty
population), test_biota.cpp (densities, fauna<=capacity, carnivore gating,
slot/point budgets, determinism + RNG isolation, v7 round-trip). Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:56:03 +02:00

7.8 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).
  • 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.
  • 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.

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