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 CosmeticManagerGameManager 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_on applies a calling switch before calling it, and switch_calling() re-points the run's class cosmetic 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:

  1. Record the fallen digger into lineage (fields above), then erase death_look.
  2. Resolve the fallen digger's permanent graveyard stone X via GameConstants.grave_slot_x(generation).
  3. VillageManager.send_off_successor() — the successor is drawn from the village, one fewer soul at home.
  4. 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) with VillageManager.apply_breach_losses().
    • Otherwise: trim 30% of inventory into a gravestone (if anything was lost) and restore food/water to 100.
  5. 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).
  6. SurvivalManager.revive() — a partial revival, not a full reset(); food/water were set above.
  7. Clear is_dead, increment generation, assign a fresh SaveManager.random_god_name(), reset the journal, mark played, emit generation_started, and resume.
  8. 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:

  1. Look up the old calling's landmark type (_LANDMARK_TYPE[old_class]) and remove every placed machine of that type from MachineManager (toast: "The old calling's landmark is left behind."). The new calling's landmark must be re-earned and re-built.
  2. 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).
  3. CosmeticManager.reseed_class_signature() — re-point only the run's class cosmetic slot to the new calling's signature; every other slot is left alone.
  4. 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.

See Skill Tree and Classes.

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