The foundation of the civilization arc: name the world so everything civic
(territory, borders, place-of-origin) can reference it. This is pure derived
geometry + a deterministic namer, so it lives in the raylib-free engine and is
fully testable headless. No agents/clock yet -- those come in later steps.
- NameGen.{hpp,cpp} (new): deterministic procedural name generator (xorshift
syllable banks; bankForRegion gives each continent a "language" so its rivers/
mountains share a sound). Reused by the whole civ arc later.
- PlanetGeography.{hpp,cpp} (new): Planet::generateGeography() extracts named
features by connectivity over the fixed grid -- continents/islands (connected
land), oceans/seas (connected water), lakes (inland filled basins), mountain
ranges + peaks (connected high terrain), rivers (largest discharge mouths
traced upstream via flowTo). Separate RNG (sGeoRng) keeps tectonic determinism
intact; per-cell index arrays (sCellLand/Water/Range/River) give O(1) lookup.
- Save v17: geography block (feature records with names + per-cell region arrays)
appended in writeState/readState; readState gains hasGeography; pre-v17 saves
load with none (regenerated on M). geo* config knobs + validation.
- Render: key M toggles place-name labels on globe (manual projection) + 2D map
(minor features only when zoomed); a 5th "Atlas" live-info tab lists features
by kind (click a row -> focusCell); cell-info shows a "region" line. Generated
lazily on a settled world (M) or on entering Live World (W).
- test_geography.cpp (new, in CMake foreach): extraction, per-cell membership,
river-traces-to-sink, names unique/deterministic, RNG isolation, v17 round-trip.
All 8 headless suites pass; GUI build clean. Docs updated (CLAUDE/design-notes/
BUILD), incl. the multi-step civilization roadmap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
69 KiB
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 modes6/7. - Biomes (done) — per-cell
Cell.biome(13 biomes incl. polar Ice) from elevation- the climate fields (
Planet::classifyBiomes), color mode5, saved per cell.
- the climate fields (
- 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) —
axialTiltdrives 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 key6cycles 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, keyWto enter once settled,[/]ramp the rate), a moving day/night terminator (sun from time-of-day rotation + seasonal declination viaaxialTilt; keyN), 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) — 1–3 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 viacosθ²−⅓, semidiurnal). Tides show as a tide-coloured coastline (buildCoastline+ a divergingtideColor, keyT, 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). KnobstideAmplitude/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 intosTempas a bounded coastal anomaly (climateCurrentFactor), so biomes shift naturally. Rendered as warm/cold current arrows over the sea (keyO, 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 (keyK). 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 +sVolRngare 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, keyV); saved (v15). Knobsvolcano*.
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). Step 1 of the roadmap is done:
- 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/seas (connected water), lakes (inland filled basins), mountain ranges + peaks (connected high terrain), rivers (largest discharge mouths traced upstream viaflowTo) — 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 (keyM, 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). Saved (v17). Knobsgeo*. Next steps (not yet built): settlements + food/habitability, territory + borders, 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.cppasserts geometry, plate assignment, non-saturation and graded relief; run it after anyPlanet::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 buildsdrift(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 bydriftIter)advect()runs a Wilson-cycle lifecycle: fission (a plate oversplitFraction(20%) of cells splits along a random great circle through its centroid; prob ramps0.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 aPlate.babyyoung-ridge strip;coalesceBabyPlates()merges connected blobs and dissolves tiny noise ones; a strip pastbabyPromoteFrac(0.7%) is promoted to a real plate with random drift +volcanicLandFracof 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 keepplatesbounded. 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 viaB). 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()(everyseaLevelEveryerode calls) easescfg.seaLeveltoward the percentile elevation leavinglandFractionTarget(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 fixedseaLevelStep(100 m) when outside aseaLevelTol(2%) deadband, and only if the nudge reduces the error (so it rests near a flat "cliff" instead of oscillating). A human-editableplanet.cfg(key=value) holds all PlanetConfig params (auto-created on first run,F2reloads + regenerates);loadConfig/saveConfigshare oneCONFIG_FIELDSX-macro. Aplanet.savebinary holds seed + config + full planet state (Planet::writeState/readState; geometry rebuilt from subdivisions viabuildGeometry());F5saves,F9loads 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-ruleoceanBase < continentBase) on load andF2; an invalidplanet.cfgreverts 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),Ffast-forward (runsstep()to settled instantly),F12screenshot,--seed/--configCLI flags, aP<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-nudgeoceanicso 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
relaxsnapped uplifted crust back tocontinentBaseonce a migrating front passed. Nowstep()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-looperode()) instead of relaxing away. These three boosts are gated on thePlanet::driftingflag — 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 runsstep()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, whereerode()runs every tick, avoids both. main.cpp setsplanet.drifting=truewhen forming settles (and inloadGamefrom the saved phase),falseon reseed/regen. Seafloor aging->depth: oceanic crust subsides withgeoAgeviaoceanicBase(age) = max(oceanBase, ridgeDepth - seafloorSubsidence*sqrt(age))(half-space cooling);oceanBaseis now the deep abyssal floor (-6000 m),ridgeDepththe shallow young value (-2500 m), andseedInitialRelief()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 inplanet.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 withlakeDepth>0above 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 upstream→downstream carrying a sediment load: stream-power incisionK*Q^m*S^n*dtwhere 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 toelevation). Orchestration (main.cpp): afterphase3AfterMydrift-My the sim pauses and prompts ("Continue Phase 2" / "Start Phase 3");Htoggles Phase 3 manually. In Phase 3 the drift loop keeps running (advect/step/erode) but at a finer dt (cflDtMy()*phase3DtScale) plushydrology(dt)— drift never stops, just resolves finer. Lakes shade inland-water blue (recolor); rivers draw as acentroid→downstreamline network (3D + 2D, two widths,Jtoggles). Save bumped to v3 (+ aphase3header 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 internalphase3*names are unchanged.) - Phase 3 increment 1 (biomes — classify + color):
Planet::classifyBiomes()(src/sim/PlanetBiomes.cpp, raylib-free) writes a per-cellCell.biome(enumBiome, 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 subtropics→deserts) + river discharge + coastal proximity; classified first-match (ice→ocean→lake→beach→mountains→hills→ lowland-by-temp/moisture). Polar ice caps fall out of the temperature test (it also snow-caps high peaks). All thresholds are tunable inplanet.cfg(thebiome*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 afterinvader;readState(is, hasBiome)reads it for v4, reclassifies for v3, so v3 saves remain loadable). Rendered as color mode5(biomeColor, src/render/Colors.cpp); re-run eachrefreshView.lakeColorchanged 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_TEMPin PlanetBiomes.cpp. Added a planetaryaxialTilt(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 anrlRotatefabout world Z wrapping all 3D content inrenderGlobe3D; 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°).axialTiltwas added to PlanetConfig (planet.cfg). Headless: ice ~14%, biome +axialTiltround-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 inplanet.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 likeplanet.cfg(writeConfigFields/parseConfigStreamshared); doubles written atprecision(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-defaultbiome*) 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. TemperaturesTemp(°C) = the latitude curve (biome*temp params) − elevation lapse. PrecipitationsPrecip: 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 diffusedclimateMoistureSmoothpasses to create wet→dry transition zones, then normalized tosMoist(0..1, median land → 0.5, robust to orographic spikes).classifyBiomes()now readssTemp/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 modes6(temperature, blue→red) /7(precipitation, dry→wet).computeClimate()runs beforeclassifyBiomes()ingenerate()andrefreshView(). Newclimate*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 derivedsTempSummer/sTempWinter(= annual meansTemp± a half-amplitudeA = seasonAmpMax·tiltFactor·latShape·continentality), wheretiltFactor = 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 viabiomeSeasonWeight(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 key6now cycles mean→summer→winter→seasonality; cell-info shows summer/winter. Newseason*biomeSeasonWeightconfig 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=0leaves 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., viacolorModeName), updating with1–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"); internalphase*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] viaPlanet::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 modes8/9/0. (2) A discrete slot/point populationPlanet::generateBiota()(keyL, 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 inorganismTaxonomy()), never an informal common name like "big cat"; generalist families get a biome adjective (Desert Muridae). Uses a separate RNG seeded fromcfg.seedso generating biota never perturbs tectonic determinism. Population is saved (sBiota, save v7); densities are derived/not-saved. New filesPlanetBiota.{hpp,cpp}+PlanetFlora/Fauna/FungiGen.cpp; color modesfloraColor/faunaColor/fungaColor; cell-info shows density % + the per-kind organism list.bio*config knobs. Headlesstest_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; pressL). - Biota — marine flora & fauna (life in the ocean): the biota layers used to be 0 on every
water cell (a hard
elevation<=seagate + no Ocean-masked archetypes), so the sea read as barren. Now ocean cells (not under polarIce) get a marine primary productivity incomputeFloraDensity:base + (1-base)·max(shelf, coast)whereshelf= shallowness (1 - depth/bioMarineShelfDepth, light to the photic floor) andcoast= 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.computeFaunaDensitynow skips onlyIce(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.generateBiotapopulates ocean cells too (skipIce; no marine funga) —fillFlora/fillFaunaare unchanged because their biome-mask filter draws only the new Ocean-masked archetypes appended tobiotaArchetypes(): marine flora Kelp / Seagrass / Phytoplankton and marine fauna Forage fish / Reef fish / Shark / Baleen whale / Seal / Squid (allmoistMin=0, SST-zoned; append-only so v7 saves are unaffected — old saves just lack them untilL). The flora/fauna color views (8/9) render ocean on a distinct marine ramp (marineFloraColordeep blue→teal/green bloom,marineFaunaColordeep blue→cyan→warm) so the sea still reads as sea; land ramps + the funga view unchanged. NewbioMarineBase/bioMarineShelfDepth/bioMarineCoastRingsconfig knobs (self-describing config → no save bump).test_biota.cppupdated: 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; declinationaxialTilt·sin(2π·doy), sub-solar longitude sweeps once per day; the foundation the future weather sim reads) andcomputeLiveSeason(doy)→sLiveTemp(the annual-meansTempswung toward the existingsummerTemp/winterTempby the seasonal phase, anti-phased across hemispheres). Viewer: keyW(settled world) toggles Live World — drift freezes andliveTimeadvances atliveRate(sim hours/real-second),[/]ramp it hour→month; the HUD shows aYear/Day/HH:MMcalendar (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 viasnowTemp/seaIceTemp→ day/night dim); both the 3D globe and 2D map drawdisplayColors()(the overlay over any colour mode),Ntoggles the terminator, a sun marker sits over the lit hemisphere. Cell-info adds alive temp / day-night / snowline. Save v8 appends the Live World flag +liveTime(version-gated; older saves load with it off). NewPlanetLive.cppin CMake + the headless list;test_live.cpp: insolation range, lit/dark hemispheres, declination tracksaxialTilt(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 1–3 moons (Moonstruct in PlanetTypes) from a separate RNG (cfg.seed ^ 0x900D5EED, tectonic stream untouched);sunDirection/moonDirection/moonOrbitNormalgive model-space sky geometry (one source of truth —computeInsolationnow callssunDirection).computeTides(doy,tod,days)→sTide(m), equilibrium two-bulge tide (Σ w·(cosθ²−⅓), moons + sun weightedtideSunFactor, scaledtideAmplitude); derived/not saved. Viewer:Tcolours the coastline (buildCoastlinedual-contour +tideColordiverging amber↔cyan, per-segment in 3D +drawColoredSegments2Din 2D), auto-scaled to the tide extent;stepSimcomputes tides +moonDirs/moonNormalseach 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 intorebuildLiveOverlay'sillumnear 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:computeTidesflood-fills connected ocean bodies and caps the amplitude of any body under 10 cells to0.01·cells + 0.03m (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 velocitysCurrent(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 tosTempbefore seasons, so summer/winter + biomes shift with it. Render:buildCurrentsemits subsampled warm/cold arrows over the sea (warm = poleward/red, cold = equatorward/blue), keyO(3D + 2D), built inrefreshView. New knobclimateCurrentFactor(4 °C).test_ocean.cppadds: 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 (usessInsolation+sTemp), advect humidity & cloud downwind (upwind differencing alongsWind/sUpwind,weatherWindKmh), condense the supersaturated air into cloud — saturationweatherSatBase + weatherSatTempCoef·T, plus windward orographic lift — rain out cloud aboveweatherRainThresh, 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 instepSim(dt = the same sim-hours added toliveTime; held when paused). Render: a translucent cloud shell (white → dark storm where it rains, alpha = cover) over the 3D globe + adrawWeather2Dlayer on the 2D map, keyK(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
stepWeathernow also runs a population of driftingWeatherSystemagents (PlanetTypes; saved v11; separatesWeatherRngseeded fromcfg.seed→ tectonic determinism intact). Each step: spawn over warm tropical ocean (5–25°, SST ≥weatherTropicalSST) or a mid-latitude (30–62°) ocean low (capped atweatherSystemMax, prob ∝weatherSpawnRate); move along the steering wind (sWindat the nearest cell) + a poleward recurve atweatherSystemSpeed; 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 pastweatherHurricaneStris a hurricane/typhoon. Render: an animated cyclonic spiral marker per system (red + eye for cyclones, blue lows; spins withliveTime·hemisphere) in 3D + 2D, HUD system/cyclone counts, and a storm list (basin-named) in the Live infoWeathertab — all underK.test_weather.cppadds: systems spawn, move between steps, thicken cloud, RNG isolation, determinism. - Live World viewer controls — storm follow-cam, 2D map zoom, clock stepper: (1)
Ycycles the 3D camera to follow a storm (by descending strength, off after the last). Tracked by a stableWeatherSystem.id(assigned at spawn fromsStormNextId; transient, not RNG); each framehandleInputpoints the camera straight at it viacamYaw/camPitchfromrotateZ(pos,+axialTilt)(model→world), 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 staymapRect(drawMapTrisnow derives y from the rect, not the fixedm.pos). Mouse-wheel over the map zooms toward the cursor (1–8×); drag pans when zoomed, else rotatesmapLon; 2D picking inverts the same rect. (3) Clock stepper: thestepSimlive body is factored intoViewer::liveAdvance(dtClock, dtWeather);.steps forward and,back byliveRatehours (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 boundedwxUndoring — 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, viawxPushSnapshot); a continuous run records a throttled snapshot (~1/sec) so a run is rewindable too.,searches the ring by time, the ring drops oldest pastwxUndoMax. The most recentwxSaveMax(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.Sin Live World aliases the forward step. - Live World event log:
liveInfoRectis 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.
Ccloses 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)
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 astd::shared_ptr<SubGrid> subgridHOOK (still null on the cell; the viewer builds subgrids on demand viaPlanet::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 recordsnearestMacro. 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
doublein 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
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)
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/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 or test_geography.cpp to run the Biota / Live World / Ocean /
Weather / Volcano / Geography 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) ·
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.
1–3 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 17; 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, and v17
appends the geography/atlas block — named features + per-cell region indices;
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).
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 +F2to 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 thebiome*temp params.climateCurrentFactor(4 °C) is the max coastal warming/cooling from ocean currents (0 = off; ocean-current arrows toggle withO). Current deflection angle + smoothing passes are constants incomputeOceanCurrents()(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 withK. 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 underK. Tune these for a stormier or calmer world. - Seasons (
season*+axialTilt+biomeSeasonWeight,planet.cfg) —axialTiltis 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 aB::Oceanbiome mask andmoistMin=0), append tobiotaArchetypes()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 thed/srate unit),yearLengthDays(365.25) the season period;axialTiltdrives 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 inrebuildLiveOverlay()(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 (1–3) and per-moon orbit/period/inclination/mass are randomized ingenerateMoons()(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, andvolcanoMaxHeight(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), andvolcanoDeadActivity(0.05). Ash FX —volcanoBlastRadius(0.09 rad),volcanoBlastCloud(1.5),volcanoAshMinYears/MaxYears(0.5/3),volcanoAshPuffCellsPerWeek(2),volcanoAshCloud(0.9), andvolcanoAshCooling(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 component ≤ 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 capsgeoMaxRivers(40) /geoMaxPeaks(40, largest/highest kept). Name flavour (syllable banks, a "language" per continent)- label fonts/colours are constants in NameGen.cpp / ViewerRender.cpp, not config.
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 atbase + 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 inplanet.cfg. - Orogeny — taller mountains (PlanetConfig, Phase 2 inc. 4):
collisionFactor(1.8, continent-continent uplift, Himalaya) andarcFactor(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 atrootScale(2500 m abovecontinentBase). These three boosts are drift-only (gated onPlanet::drifting); in Phase-2 drift the per-tickerode()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 (instep(),erode()andhydrology()) flattened many drift-time peaks into a 9000 m plateau. Now growth is probabilistic abovepeakSoftCapStart(7000 m): the chance a tick's positive uplift "takes" falls linearly to 0 atpeakSoftCapEnd(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 random0..peakFailDrop(200 m) off, so a peak hovers near its own height (height now tracks orogeny strength: strong seeds reach 10–11 km, most cluster lower). The hard clamp's upper bound now trackspeakSoftCapEndin all three places (just a safety rail; lower −11000 m unchanged). The roll is a pure hash of(cellIndex, erodeIter, seed)— never touchesrngState, stays bit-identical across OpenMP thread counts, and sinceerodeIteris saved (step/erode run 1:1 in drift) F5/F9 resumes bit-identical (no save-version bump). Drift-only (gated ondrifting) so Phase-1 forming still auto-settles. TunepeakSoftCapStart/peakSoftCapEnd/peakFailDropinplanet.cfg. Verified headless: smooth 8.5→10.5 km taper, 0 cells pinned at the ceiling, determinism + exact resume intact. - Seafloor aging->depth (PlanetConfig, Phase 2 inc. 4):
oceanBaseis now the deep abyssal floor (-6000 m, not a flat ocean base) andridgeDepththe shallow young value (-2500 m);seafloorSubsidence(280 m per sqrt(My)) sets how fast oceanic crust deepens withgeoAge(half-space cooling), andseafloorSeedAge(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);riverIncisionK (0.02) +riverDischargeExpm (0.5) +riverSlopeExpn (1.0) — stream-power incisionK*Q^m*S^n*dt(raise K for faster valley carving);riverTransport(0.1) — transport capacitycap=this*Q*SanddepFrac(0.25) — deposition rate of excess load (raise both for more deltas / faster lake infill). All editable inplanet.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 insrc/render. - Geometry stays fixed — never make cells move; add new per-cell properties and flow them over the existing grid + neighbor adjacency.