Save System and Migration

SaveManager (scripts/core/SaveManager.gd, extends Node) owns all persistence: per-run save slots, the profile-holding config.json, versioned migration, and corrupt-slot quarantine. Below: the on-disk layout, the current save version and its migration steps, the run blob's shape, the config's v2 profile shape, and what lives in config.json versus settings.json. For the profile behavior (chip, picker filtering, claim/reassign flows) see Player Profiles.

On Windows the user data directory is %APPDATA%\Godot\app_userdata\Underroot\.

Save slots

Runs are stored as user://saves/slot_N.json. The slot count is MAX_SLOTS = 12 (a household picker of 4 cards across, 3 rows); slot index runs 0..11. _get_slot_path(idx) builds each path:

const SAVE_DIR     := "user://saves/"
static var CONFIG_PATH := "user://config.json"
const SAVE_VERSION := 5
const MAX_SLOTS    := 12

CONFIG_PATH is a static var rather than a const for exactly one reason: the headless smoke harness repoints it at a scratch file so a dev gate can never write the player's account data. Nothing in the game ever reassigns it. See Verification and CI.

list_slots() returns a summary dictionary per existing slot (idx, player_name, days, depth, blocks_mined, discovery_pct, generation, is_dead, timestamp, harrow, cosmetics — the per-slot loadout the picker portrait renders — and profile_id, the owner tag the picker's profile filtering and owner labels read) and cleans up as it reads: 0-byte files left by an interrupted pre-atomic save are deleted, and unparseable slots are skipped.

Writes are atomic. save_game() serialises the whole payload before touching the slot file, then writes to slot_N.json.tmp and renames it over the real file — an interrupted or failed write can never truncate a good save to 0 bytes:

var payload := JSON.stringify(_build_save_data())
if payload.is_empty() or payload == "null":
    push_error("[SaveManager] empty save payload — keeping previous save")
    return
var tmp := path + ".tmp"
var file := FileAccess.open(tmp, FileAccess.WRITE)
file.store_string(payload)
file.close()
if FileAccess.file_exists(path):
    DirAccess.remove_absolute(path)
DirAccess.rename_absolute(tmp, path)

The save is unindented (it runs every 20 s on the main thread; pretty-printing adds ~35% to build and write time). Imported saves and account-bundle restores write with "\t" indentation instead.

Deletion is soft. delete_slot() renames the file to slot_N.deleted.json — a one-deep trash (a newer delete of the same slot clobbers the older trash). Every deletion path funnels through it: the picker's † tile delete, abandon_slot(), delete_profile()'s delete-with-saves, and reset_active_profile(). list_slots() never sees trash (it probes exact slot_N.json paths). The trash is never reaped on a timer — deliberately, so a deletion noticed weeks later is still recoverable; it only disappears when the same slot is deleted again or on a full factory reset. Worst case is 12 files at ~200 KB each.

Restoring from the trash (there is deliberately no in-game restore UI yet — revisit if household use demands it):

  1. Close the game (an autosave would fight the edit).
  2. In user://saves/ (Windows: %APPDATA%\Godot\app_userdata\Underroot\saves\), rename slot_N.deleted.jsonslot_N.json.
  3. The slot index is only the filename — if that index is now occupied by a newer run, rename to any free index instead (slot_5.json).
  4. If the run belonged to a since-deleted profile, it only shows under the picker's "Show all"; use the tile's ⇄ reassign menu to hand it to the right player (or it can be claimed as unclaimed after ⇄ → Unclaimed).

SAVE_VERSION and migration

The current format is SAVE_VERSION = 5. Every load routes through _migrate(), which upgrades an older dictionary one step at a time and stamps the result:

func _migrate(data: Dictionary) -> Dictionary:
    var version := int(data.get("version", 1))
    if version < 2:
        # v1 -> v2: modified_tiles moved to compact arrays
        version = 2
    if version < 3:
        # v2 -> v3: the Maw became multi-front (maw_fronts)
        version = 3
    if version < 4:
        # v3 -> v4: cosmetics loadout became per-slot; profile_id reserved
        var run_v: Variant = data.get("run")
        if run_v is Dictionary:
            var run_d := run_v as Dictionary
            if not run_d.get("cosmetics_loadout") is Dictionary:
                run_d["cosmetics_loadout"] = get_cosmetics_loadout()
            if not run_d.has("profile_id"):
                run_d["profile_id"] = ""
        version = 4
    if version < 5:
        # v4 -> v5: skill/class state rides the run blob
        var run5: Variant = data.get("run")
        if run5 is Dictionary:
            var run5d := run5 as Dictionary
            if not run5d.get("skills") is Dictionary:
                run5d["skills"] = {}
            if not run5d.has("class"):
                run5d["class"] = ""
        version = 5
    data["version"] = version
    return data

To add a format change: bump SAVE_VERSION and add a matching if version < N: block in _migrate(). Prefer tolerant readers (accept both shapes) over in-place rewrites where possible, following v2/v3.

The save payload

_build_save_data() assembles the top-level dictionary. Alongside version and player_name, it gathers each autoload's own get_save_data():

Top-level key Source
run GameManager.current_run (the run blob, below)
inventory Inventory.get_save_data()
tools ToolState.get_save_data()
world seed, modified_tiles, walls, rooted walls, gravestone, mix_discovered
forest nodes, bushes, pond/boulder state
rocks surface rock depletion
maw_adaptation MawAdaptation.get_save_data()
tasks / machines / crafting_unlocks idle queue, machines, unlock state
survival / village / projects manager save blobs
lineage / discovery_pct / appearance_seed / day_time run metadata

The Maw's payload spans several top-level keys (a legacy layout), so MawController.append_save_data(out) appends directly to the dictionary rather than nesting one section.

lineage

lineage is GameManager.lineage saved wholesale — one entry per fallen digger, carrying name, gen, days, depth, tiles, rituals, cosmetics, class, and successor_relation. The four counters are snapshots of run-cumulative values at that digger's death, so a generation's own contribution is the gap between consecutive entries.

tiles / rituals / cosmetics / class were added later and are purely additive — entries written before them simply lack the keys, and no SAVE_VERSION bump was needed (the same treatment as villager_deaths). The reader tolerates their absence and backfills what it can from the profile's TOP RUNS rows. See GameManager and the Run Lifecycle.

The run blob

run is GameManager.current_run saved wholesale, so any key the run holds rides along without a version bump. Fields worth knowing (per the architecture guide and the save code):

Field Default on old saves Meaning
is_dead derived heuristic Authoritative death flag (older saves fall back to a food/water/front_x check).
days_survived / depth_reached / blocks_mined 0 Progress counters surfaced in the slot picker. Run-cumulative — they survive a generation death.
generation 1 Lineage generation.
death_look absent Transient: the digger's look and calling at the moment of death, moved into the lineage entry on carry-on and erased.
villager_deaths 0 Graveyard cross count; rides the run blob, no version bump.
tutorial_clock_frozen false Two-phase tutorial flag; cleared on any load.
first_task_released false First-task gate.
run_uuid "" Identity for the shared-run lock and async slot writes. Minted once per run, not per generation — which is what lets a TOP RUNS row be joined back to a lineage entry by run_uuid + gen.
challenges [] Active challenge ids.
harrow {} Artificer's Harrow design (name surfaces in scores).
skills / class seeded by v5 migration Per-run skill XP and the chosen champion calling.
black_hollow_cooldown 0 Days until Wren offers another expedition; ticked once per new_day. Started only on a successful bank, never on a bust or in story mode.
cosmetics_loadout seeded by v4 migration Per-slot equipped cosmetics — the run's look. Reached only via CosmeticManager._run_loadout(); equip() writes it (and mirrors the account template); reset_run_equipment() bares only this copy on a new game.
profile_id "" = unclaimed Owner profile tag. Stamped with SaveManager.active_profile_id() on every new run; an untagged (pre-profile) save is adopted by whoever continues it (GameManager._apply_save_data; the TitleScreen confirms the claim once several profiles exist). Drives slots_tagged(), delete_profile()'s delete-with-saves, the reassign menu (retag_slot), and the picker's owner filtering.

Two helpers write the run without rebuilding the whole file. update_run_values(values, expected_uuid) does a load-modify-write of the raw slot JSON (used by the title screen's dead-slot share); when expected_uuid is set the write is identity-gated on run.run_uuid so a late async caller cannot stamp a slot that has since changed. It must not be called while a run is hydrated — the next save_game() would rebuild the file from current_run and discard the write. retag_slot(idx, pid) shares the same raw-write constraint.

Corrupt-slot quarantine

A slot that fails to parse (or whose root is not a dictionary) is not deleted — load_game() calls _quarantine_slot(), which renames it to slot_N.corrupt.json so the player keeps the file for recovery while the game boots cleanly to an empty slot instead of crash-looping:

func _quarantine_slot(path: String) -> void:
    var backup := path.trim_suffix(".json") + ".corrupt.json"
    if FileAccess.file_exists(backup):
        DirAccess.remove_absolute(backup)
    DirAccess.rename_absolute(path, backup)
    EventBus.show_toast.emit(
        "A save file was damaged and has been set aside as %s." % backup.get_file(),
        Color(0.92, 0.45, 0.20), 8.0)

config.json — the profile store (v2)

config.json survives slot deletion and, since CONFIG_VERSION = 2, holds player profiles:

{
  "config_version": 2,
  "active_profile": "p1",
  "profiles": { "p1": { "name": "Player 1", "...": "all personal keys" } }
}

The only reserved root keys are config_version, active_profile, and profiles; every personal key lives inside a profile blob (unknown keys are personal-by-default and migrate into the profile too).

Migration is on read. _load_config() passes every successfully parsed dict through _migrate_config() — a pure, idempotent function that wraps a flat v1 config (or {}) into profiles.p1 and sanity-repairs a v2 dict (active_profile must exist in profiles). All three read paths migrate: the normal path, the .tmp/.bak recovery path (migrated before the re-materialising write), and the empty-state fallback, which returns a migrated skeleton rather than {} — a bare {} would hand accessors a detached profile dict whose writes silently vanish. _save_config() stays shape-agnostic. The second entry point for external dicts, import_account_bundle(), migrates the imported config before persisting, so pre-profile backup codes restore correctly. One caveat: immediately after the first v2 write, config.json.bak still holds the v1 shape — harmless, since any recovery read re-migrates.

Accessors are profile-scoped. Every public getter/setter (has_played_before(), get_cosmetics_owned(), has_seen_nudge(), scores, codes, harrow designs, lifetime counters, …) reads and writes the active profile's blob through the private _profile(cfg) helper, which returns the blob by reference from an already-migrated dict — the long-standing read-modify-write pattern (_load_config() → mutate → _save_config(cfg)) is unchanged. Public signatures did not change; no call sites moved.

Because every writer read-modify-writes the whole file, two processes touching config.json at once is a lost-update race, not a merge. That is why the smoke harness now sandboxes CONFIG_PATH — it used to silently revert flags a running game had written.

Profiles API (surfaced by the TitleScreen — see Player Profiles for the flows):

Method Behaviour
active_profile_id() / active_profile_name() Current profile ("p1" / its name).
profiles_list() [{id, name}] for every profile.
set_active_profile(id) Validates, persists, emits EventBus.profile_changed(id). CosmeticManager reloads on that signal; the TitleScreen does a full scene reload.
create_profile(name) Cap MAX_PROFILES = 8, names trimmed to 16 chars; returns the new id or "".
rename_profile(id, name) Renames; same name rules.
delete_profile(id) Deletes the profile AND its tagged save slots (design ruling: deleting your profile means deleting your data — the slots land in the one-deep trash). Refuses the active profile and the last remaining one. Untagged saves are never touched. Gated behind a typed-name confirmation listing the doomed runs.
slots_tagged(id) Slot indices owned by a profile — the delete confirmation lists these.
retag_slot(idx, pid) Rewrites a slot's owner tag in place ("" = back to unclaimed; unknown profiles refused). Raw file write — picker-time only, never while a run is hydrated.
reset_active_profile() The gentle reset scope: deletes the active profile's tagged saves (via the trash) and resets its blob to name-only. Other profiles, unclaimed slots, and settings.json untouched.

The write/read hardening predates profiles and is unchanged:

Per-profile keys, grouped:

Group Keys
Identity name
First-run and tips played_before, tips_enabled, tutorial_nudges_seen, bell_tip_seen, rename_tip_seen, default_name_index
Scores and sharing scores (per-player bucketed, capped at MAX_SCORES = 24), shared_runs (run_uuid -> public url)
Codes redeemed_codes (one-time codes are once per profile)
Cosmetics and songs cosmetics_owned, cosmetics_loadout (the new-run template — the per-run truth rides the save's run.cosmetics_loadout), songbook_unlocked
Challenges ritual_seen_once, challenges_unlocked, challenges_earned
Artificer's Harrow harrow_announced, harrow_intro_seen, harrow_designs (capped at 12), harrow_last
Black Hollow chronicle_notes (recovered note ids), black_hollow_schematics (0–5), and the black_hollow sub-dict: best (per-expedition best depth), bands (one-time band-reached flags), story_unlocked (story-mode route chain), story_best (per-expedition best story score)
Task and lifetime stats tasks_fulfilled, tasks_denied, total_rituals_fired, total_tiles_dug, total_discoveries_found, total_gold_collected

The Black Hollow group is deliberately split by mode: best, bands and black_hollow_schematics are written only by real expeditions, because a free-play story dive with no stakes must not move a record — or grant an endgame unlock — earned under real conditions. chronicle_notes is the one key both modes write. All of it defaults via .get(), so no SAVE_VERSION or CONFIG_VERSION bump was needed for any of it. See Black Hollow.

Lifetime counters only ever move forward, written at run-end by GameManager._flush_lifetime_stats (delta-based, never per gameplay event, to avoid churning the disk) — and they follow the person, spanning that profile's slots and lineages. Scores carry the full ledger payload per run so a run can be submitted online even after its slot is deleted; save_score() runs on every death screen, so each fallen generation leaves a row carrying its portrait and totals (see the lineage section above).

The smoke test (tools/smoke_test.gd) exercises the recovery behaviors — stranded-.tmp recovery, damaged-file fallback to .bak with quarantine, and .bak rotation on save — with flat v1 marker dicts asserted through the migrated shape, proving crash-recovery and migration compose. It also covers the profile core (v1→v2 key placement, idempotency, per-profile isolation, delete-with-saves, retag_slot, reset_active_profile, the profile_id slot-summary exposure), the one-deep trash (rename-not-remove, full payload in the trash file, harness sweeps its own trash), and the v4 slot material (per-slot loadout round-trip, run-only reset scope, synthetic v3 migration). All of it now runs against a scratch user://smoke_config.json, never the real file. One hard-won rule for future smoke work: the harness's own scratch save is stamped with the real machine's active profile id, so profile assertions must not assume disjointness from real ids — the harness retags its own slot into the scratch world first.

settings.json

Device preferences live in a separate file, user://settings.json, owned by the settings panel (scenes/ui/SettingsPanel.gd) — not by SaveManager. It holds:

Key Meaning
music, music_volume Audio preferences.
low_perf_mode, perf_suggestion_dismissed, erosion_amount Performance Mode state.
offline_mult, donate_clicked Donate-gated offline-speed perk.
hud_style HUD skin, "classic" or "talismans", machine-wide (deliberately not per-profile — the Legacy UI is transitional). Fresh machines (no stored key, has_played_before() false) default to "talismans"; machines that predate the skin default to "classic". Flipped by the Settings > "Legacy UI" toggle.
ledger_hold TAB-ledger mode: false = press-to-toggle (default), true = hold-to-peek. SettingsPanel writes this key on every save, so a player who has ever saved a setting keeps their explicit choice; the default only reaches those who never chose.

One writer, one exception. SettingsPanel persists every key on each save. The single exception is HudShell._persist_style() (scripts/ui/HudShell.gd): when a fresh machine resolves its default skin, HudShell immediately writes hud_style back with a merge-write (re-read file, set only hud_style, write merged dict) so keys SettingsPanel already stored survive. Without that one-time write, finishing the tutorial (which flips played_before) would silently drop the player back to classic on the next boot.

settings.json is deliberately preserved across every reset scope: music volume, low-perf mode, and the HUD skin choice are device preferences rather than progress, and donate_clicked (the offline-speed perk gate) must survive so a supporter keeps the perk.

Reset scopes and portable codes

Settings → Reset Game (scenes/ui/ResetConfirmModal.gd, typed-RESET guard, backup Download/Copy buttons) offers two scopes once several profiles exist, defaulting to the gentler one:

Solo machines see the single full reset, as before. Both scopes restart at the intro, and the caller must reload config-derived autoload caches (currently CosmeticManager.reload_from_config()).

Saves are also portable as UROOT1- codes: export_save_code() DEFLATE- compresses and base64-wraps a single slot's JSON; export_account_code() wraps every slot plus config.json as an account_bundle — with config v2 the bundle therefore snapshots every profile automatically. decode_save_code() accepts either a code or a raw JSON paste, and the import path validates shape before writing (import_run_data / import_account_bundle; the imported config is migrated through _migrate_config() before _save_config(), so old flat-config bundles restore correctly and the pre-import profile survives one save as config.json.bak). The encoding is portability and light obfuscation, not tamper protection — anyone can decode and re-encode it.

Key files