GameManager and the Run Lifecycle
GameManager (scripts/core/GameManager.gd) owns the authoritative run state and everything that happens to it: start, pause, death, carry-on through a lineage, reset for a fresh run. The four pieces worth knowing are the current_run dictionary, the pause model, reset_for_new_run(), and the revive/lineage path.
current_run: the run state blob
current_run is a plain Dictionary holding everything specific to the run in progress. It's the object serialized into a save slot and restored on load. The literal declared in GameManager shows its shape and defaults:
| Key | Type | Meaning |
|---|---|---|
days_survived |
int | Day count; bumped on each new_day. |
depth_reached |
int | Deepest tile Y mined this run. |
depth_reached_x |
int | X of the deepest tile (set alongside depth_reached). |
blocks_mined |
int | Lifetime-of-run tiles dug. |
gold_collected |
int | Run-cumulative gold earned (not spendable balance). |
astrolabe_uses |
int | Run-cumulative rituals fired. |
stats_flushed |
Dictionary | Per-run baseline of already-flushed lifetime totals (prevents double-counting across a lineage). |
total_wall_hp |
int | Sum of built wall HP. |
is_dead |
bool | Set true on death; guards double-death. |
start_timestamp |
int | Unix time the run began. |
cause_of_death |
String | Death cause string. |
death_look |
Dictionary | Transient: how the current digger looked when they fell (cosmetics, class). Written by _on_player_died, consumed and erased by continue_as_relative. |
generation |
int | Lineage generation (starts at 1, increments on carry-on). |
villager_name |
String | Current digger's name. |
discovery_types_found |
Dictionary | Set of discovery type ids found (completion metric). |
discoveries_found |
int | Run-total discovery pockets dug up. |
machines_built |
Dictionary | Set of machine types built (completion metric). |
challenges |
Array | Active Challenge ids for this run. |
class |
String | Chosen champion class ("" = classless). Owned by SkillManager. |
skills |
Dictionary | Per-skill XP {skill_id: xp}. Owned by SkillManager. |
cosmetics_loadout |
Dictionary | Per-run equipped look (slot -> item + colour keys), incl. the class slot. Owned by CosmeticManager. |
tutorial_clock_frozen |
bool | Two-phase tutorial gate (freezes Maw + day clock). |
village_chain_done |
bool | Staged elder chain complete. |
villager_deaths |
int | Cumulative villager deaths (graveyard cross count). |
run_uuid |
String | Stable per-run id for idempotent community-stats submission. Minted once per run in reset_for_new_run() — it does not change on a carry-on, so every generation of a line shares it. |
peaks |
Dictionary | Per-material lifetime peaks (community stats). |
history |
Array | Per-day progression rows [day, depth, blocks, pop, villager_deaths]. |
Several keys are absent in older saves and back-filled on load in _apply_save_data() (for example blocks_mined, gold_collected, run_uuid, peaks, history), so the run blob can evolve without a save-version bump for additive fields. GameManager maintains these fields live off EventBus signals connected in _ready() — tile_dug bumps blocks_mined/depth_reached, wall_built adds to total_wall_hp, discovery_found bumps discoveries_found, inventory_added accumulates gold_collected, and new_day advances days_survived and records the day's history row. The class/skills keys are read and written through SkillManager (get_class_id/set_class_id, _skills()), and cosmetics_loadout through CosmeticManager — GameManager just carries them in the blob.
Counters are run-cumulative, not per-generation. days_survived, depth_reached, blocks_mined and astrolabe_uses all survive a generation death; only generation changes. Anything that wants a single digger's contribution has to difference two snapshots — see The lineage record below.
Alongside current_run, GameManager holds run-adjacent state as members: lineage (an Array[Dictionary] of fallen diggers), appearance_seed (per-save village skin seed), crafts_underway, and the tutorial flags (tutorial_guided, tutorial_opening_active, show_tutorial_on_load).
Pause
Pausing routes through the scene tree's own pause flag and emits paired signals:
func pause_game() -> void:
is_paused = true
get_tree().paused = true
game_paused.emit()
func resume_game() -> void:
is_paused = false
get_tree().paused = false
game_resumed.emit()
Separately, full-screen menus (settings, help) register an input block rather than pausing the whole tree. set_world_input_blocked(source, blocked) keys entries in _input_blockers by source string, and is_world_input_blocked() returns true while any entry exists. World.gd consults this to ignore world hotkeys and manual camera panning behind an open overlay without freezing simulation.
Death, lifetime stats, and lineage
When the player dies, EventBus.player_died fires (a Maw breach routes through maw_breached_base, which emits player_died(CAUSE_MAW_BREACH)). _on_player_died() guards against a double death, stamps is_dead and cause_of_death, snapshots how the digger looked, flushes lifetime stats, saves, and pauses:
func _on_player_died(cause: String) -> void:
if current_run.is_dead:
return
current_run.is_dead = true
current_run.cause_of_death = cause
current_run["death_look"] = {
"cosmetics": CosmeticManager.get_loadout().duplicate(true),
"class": SkillManager.get_class_id(),
}
_flush_lifetime_stats()
SaveManager.save_game()
pause_game()
The look is captured here and not in
continue_as_relative().GameOverScreen._carry_onapplies a calling switch before calling it, andswitch_calling()re-points the run'sclasscosmetic slot — so a snapshot taken at carry-on would bury the ancestor wearing their successor's clothes.
_flush_lifetime_stats() is delta-based: it adds only the growth since the last flush into the account-lifetime counters on SaveManager, using current_run.stats_flushed as the moving baseline. Because a lineage keeps accumulating per-run counters across generation deaths, the baseline is what prevents double-counting; each generation death flushes that generation's delta.
The lineage record
GameManager.lineage is an Array[Dictionary], appended to once per fallen digger in continue_as_relative():
| Field | Meaning |
|---|---|
name |
The digger's name (falls back to Villager #N). |
gen |
Their generation. |
days / depth / tiles / rituals |
Snapshots of the run-cumulative counters at the moment they fell. |
cosmetics / class |
Their look and calling, from death_look. |
successor_relation |
Drawn at random from RELATION_POOL — which is not only family (neighbor, best friend, hunting partner, old rival). A lineage is a chain of succession, not a family tree. |
Because the four counters are snapshots, a generation's own contribution is the gap between consecutive entries. LineageOverlay._rows() is the only reader that cares; it differences them and reports the living digger against the last entry. tiles/rituals/cosmetics/class are additive fields — entries written before they existed simply lack the keys, and no SAVE_VERSION bump was needed.
For those older entries there is a second source: SaveManager.save_score() runs on every death screen and stores the fallen digger's cosmetics, blocks and astrolabe_uses in the profile's TOP RUNS rows. Since run_uuid is minted once per run rather than per generation, run_uuid + gen joins a score row back to its lineage entry exactly. Treat it as backfill, not storage: _trim_scores() keeps only MAX_SCORES = 24 rows across buckets, so a middling generation can be evicted. A digger with neither source renders a plain headstone and "no tally kept".
The overlay itself is documented in The Talismans HUD.
Carry on: continue_as_relative()
Death is not the end of a run — a successor from the village can take up the dig. continue_as_relative(queue_tutorial) is called by both CARRY ON (death screen) and SEND ANOTHER (title screen). The world, Maw, and machines persist; only the digger and a slice of inventory change. The sequence:
- Record the fallen digger into
lineage(fields above), then erasedeath_look. - Resolve the fallen digger's permanent graveyard stone X via
GameConstants.grave_slot_x(generation). VillageManager.send_off_successor()— the successor is drawn from the village, one fewer soul at home.- Branch on cause of death:
- Maw breach (
cause == CAUSE_MAW_BREACH): collapse the tunnel, trim 30% of inventory (plus 3 strange root) into a gravestone,MawController.breach_retreat(), set food/water to 60, and apply a randomised village population toll (a third to two thirds lost, floored) withVillageManager.apply_breach_losses(). - Otherwise: trim 30% of inventory into a gravestone (if anything was lost) and restore food/water to 100.
- Maw breach (
- Safety net: if
MawController.any_front_breached(), retreat any still-breached front (starvation can win the death race the same frame the Maw is genuinely breached). SurvivalManager.revive()— a partial revival, not a fullreset(); food/water were set above.- Clear
is_dead, incrementgeneration, assign a freshSaveManager.random_god_name(), reset the journal, mark played, emitgeneration_started, and resume. - Teleport the successor to spawn beside the gravestone via
EventBus.player_teleport_requested.
generation_started (step 7) is what halves every skill for the heir — SkillManager._on_generation_started snaps each skill down by the inherit_factor. If the heir switched calling first (below), that halving reads against the new champion status. Carry-ons never re-run the champion head-start; the heir keeps its inherited (halved) XP.
queue_tutorial decides whether the successor re-enters the guided spine: true sets show_tutorial_on_load (which TutorialOverlay._ready() consumes to start the spine directly), false emits tutorial_skip_requested. The death screen's CARRY ON passes the default false — the tutorial state is already resolved in-session. The title screen's SEND ANOTHER rebuilds the world from scratch, so it passes not SaveManager.has_played_before(): a new player who fell mid-tutorial resumes guidance, a veteran never does. Queueing it unconditionally used to strand veterans — the spine's gather beats wait on rock_harvested, and surface rocks never respawn, so on an established world the beat could not complete. The rock beats now also skip themselves when no unstripped rock remains.
Carry-on deliberately does not call
reset_for_new_run(). The world and its progress survive; the lineage continues in the same slot.
Switch calling: switch_calling()
At the CARRY ON screen the heir may change champion class before continuing (a "Change calling" button opens the class picker in switch mode; classless lineages don't see it). The pick runs switch_calling(new_class_id) before continue_as_relative, so the death-halving in step 7 uses the new champion status. It is a no-op when new_class_id equals the current class. The sequence:
- Look up the old calling's landmark type (
_LANDMARK_TYPE[old_class]) and remove every placed machine of that type fromMachineManager(toast: "The old calling's landmark is left behind."). The new calling's landmark must be re-earned and re-built. SkillManager.set_class_id(new_class_id)— moves the champion. The new home skill can now pass node 5 (toward 7); the old one caps at 5. Skill XP itself is untouched here (the halving happens later in the carry-on).CosmeticManager.reseed_class_signature()— re-point only the run'sclasscosmetic slot to the new calling's signature; every other slot is left alone.CraftingUnlockManager._check_unlocks()— re-gate recipes so the new calling's landmark blueprint appears (and the old one's disappears) per the champion-skill node gate.
reset_for_new_run()
A true new run wipes the slot and returns every shared singleton to a clean baseline. reset_for_new_run() resumes the tree, clears input blockers and in-flight crafts, deletes the save, rebuilds current_run from defaults (assigning a fresh run_uuid via CommunityStats.uuid4(), pulling in any pending ChallengeManager/HarrowManager selection, and applying SkillManager.pending_class as the chosen class), then resets each subsystem in order:
SurvivalManager.reset(GameConstants.STARTER_FOOD, GameConstants.STARTER_WATER,
GameConstants.STARTER_STORED_FOOD, GameConstants.STARTER_STORED_WATER)
MawController.reset()
MawAdaptation.familiarity.clear()
Inventory.resources.clear()
ToolState.reset()
CosmeticManager.reset_run_equipment() # bares headwear/extra/form, seeds the class signature
MachineManager.reset()
WorldManager.reset_world(randi())
...
SkillManager.reset()
SkillManager.seed_champion_headstart() # champion starts at home-skill node 1 (+4% from turn one)
...
ChallengeManager.apply_run_start() # applied last so challenge start conditions win
The reset()/clear() sequence and the reasoning behind the ordering are detailed in The Autoload Model. The key contract: challenge starting conditions are applied last so they override the normal manager resets.
SkillManager.reset() wipes per-run XP, then seed_champion_headstart() pre-fills the chosen champion's home skill to node 1 — a readable +4% from turn one. It's a new-game-only step: a classless run is a no-op, and lineage carry-ons (which go through continue_as_relative, not this function) are never re-seeded, so an heir keeps its inherited, halved XP. See Skill Tree and Classes.
Loading a run
trigger_load() reads the slot via SaveManager.load_game() and, if non-empty, hands it to _apply_save_data(). That function is defensive: it drops any top-level section whose JSON type does not match what its loader expects (so a hand-edited or imported save degrades to "missing" rather than crashing mid-load), restores current_run from the run section, back-fills newly added fields, and then hands each section to its owning singleton's loader (Inventory.load_save_data, ToolState.load_save_data, SurvivalManager.load_save_data, MawController.load_save_data, and so on). It finishes with MawController.refresh_threat_state() so the UI opens in the correct threat colour.
Key files
scripts/core/GameManager.gd—current_run, pause, death, lineage,switch_calling(),reset_for_new_run(), load.scripts/core/GameConstants.gd—STARTER_*stores andgrave_slot_x().scripts/core/SaveManager.gd— slot persistence, lifetime-stat counters, TOP RUNS rows.scripts/core/EventBus.gd— the signals that keepcurrent_runcurrent.scenes/ui/LineageOverlay.gd— the only reader that differences the lineage snapshots.