C++/raylib semi-realistic fantasy/sci-fi planet generator on a fixed icosphere grid (Eulerian: properties flow over fixed cells). World-creation stages: tectonics, continental drift & erosion, hydrology (rivers/lakes), climate (temperature + orographic precipitation), and biome classification. Engine in src/sim (raylib-free, headless-testable), viewer in src/render. See CLAUDE.md and docs/ (design-notes.md, fauna-flora-plan.md = next step). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5.7 KiB
Design notes (durable context)
These are the non-obvious decisions/conventions that were previously only in Claude's
auto-memory (which lives under ~/.claude/ and does not travel with the repo). Captured
here so the context survives a move to another machine/server. CLAUDE.md has the
authoritative current-state changelog; this is the "why / where things live" summary.
Framing: World Creation → Live World
The roadmap is no longer rigid numbered "phases". World Creation is a set of
continuous, overlapping stages on a geological clock (My): tectonics → continental drift &
erosion → hydrology → climate → biomes → (fauna & flora, next). The long-term goal is a
separate Live World mode that runs the finished planet at a much slower real-time
clock (hours/days/weeks/months) with dynamic weather (clouds, rain, storms) and living
ecosystems/civilization. Internal code still uses phase* names (Planet::drifting,
the phase3 flag, phase3AfterMy/phase3DtScale config keys) for save/config
compatibility — only display strings and docs use the new framing.
Code module layout
Split into a raylib-free engine (src/sim/, headless-testable) and a raylib viewer
(src/render/); src/main.cpp is a ~10-line entry point. CMake adds both dirs to the
include path, so includes stay flat (#include "Planet.hpp", "Viewer.hpp").
Planet is one class implemented across several .cpp files (all share Planet.hpp):
PlanetTypes.hpp—Cell/Plate/SubGrid/Biomeenum /PlanetConfig.Planet.cpp— generation, geometry, plate seeding, RNG + shared helpers, subgrid, min/max.PlanetTectonics.cpp—step()(stress→uplift→relax; orogeny boosts gated ondrifting).PlanetDrift.cpp—cflDtMy/advect+ plate lifecycle (fission/kick/baby/fuse/enclosed).PlanetErosion.cpp—erode+adjustSeaLevel.PlanetHydrology.cpp—routeFlow/computeHydrology/hydrology(depression-fill→lakes, steepest-descent→rivers, mass-conserving stream-power incision).PlanetClimate.cpp—computeClimate()(temperature + orographic precipitation).PlanetBiomes.cpp—classifyBiomes()(per-cellCell.biomefrom elevation + climate).PlanetIO.cpp— text config + binary save/load.- (
PlanetBiosphere.cpp— fauna/flora, planned; seefauna-flora-plan.md.)
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() → (computeBiosphere() when added) → recolor().
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 (v6) — self-describing config
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).
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 (
climateMoistureSmoothpasses) into transition zones, then normalized tosMoist∈[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/PlanetIO.cpp -o /tmp/t && /tmp/t
(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).