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_PATHis astatic varrather than aconstfor 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):
- Close the game (an autosave would fight the edit).
- In
user://saves/(Windows:%APPDATA%\Godot\app_userdata\Underroot\saves\), renameslot_N.deleted.json→slot_N.json. - 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). - 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
- v1 to v2 —
modified_tilesmoved from per-tile dictionaries to compact arrays:[x, y]for a dug tile,[x, y, mat, hp, max_hp]for a damaged one. Metadata-only bump:WorldManager.load_from_saveaccepts both shapes and rewrites compact on the next autosave. - v2 to v3 — the Maw became multi-front. Metadata-only bump:
GameManagerrebuildsfronts[0]from the legacymaw/cooldowns whenmaw_frontsis absent, then writesmaw_frontson the next save. - v3 to v4 — the cosmetics loadout became per-slot (
run.cosmetics_loadout). Unlike the earlier steps this one rewrites data: it seeds the run's loadout from the account template inconfig.json(so an existing save keeps its look exactly — the seed must land beforeCosmeticManager's lazy accessor touches the run), and reservesrun.profile_id = ""for the Player Profiles project. - v4 to v5 — skills and the champion class joined the run blob for the
Skill Tree work. Seeds
run.skills = {}andrun.class = ""so an existing save loads as classless with no skill progress rather than tripping the accessors;SkillManagerfills both from there on.
To add a format change: bump
SAVE_VERSIONand add a matchingif 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.jsonat once is a lost-update race, not a merge. That is why the smoke harness now sandboxesCONFIG_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:
- Write with rotation —
_save_config()builds the payload toconfig.json.tmp, retires the previousconfig.jsontoconfig.json.bak, then moves the temp into place. A kill at any step leaves either an intact config or a recoverable.tmp/.bakpair. - Read with recovery — a
config.jsonthat exists but fails to parse is quarantined asconfig.corrupt.json; the load falls back to.tmpthen.bak, and only re-materialisesconfig.jsonwhen the file is truly gone.
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:
- Only the active player —
reset_active_profile(): deletes their tagged save slots (through the one-deep trash) and resets their profile blob to name-only. Other profiles, unclaimed slots, andsettings.jsonare untouched. - Everything (all players) —
factory_reset(): erases every save slot (including.corrupt,.deleted, and stray.tmpfiles), the legacyuser://save.json,config.jsonand its recovery copies (config.json.tmp,config.json.bak,config.corrupt.json— otherwise_load_config()would resurrect the profile from them on the next boot), and local telemetry, then clears the active slot.
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
scripts/core/SaveManager.gd— slots (incl. the one-deep trash), migration (_migratefor slots,_migrate_configfor the profile store), config, profiles API, scores, portable codes, both reset scopes.scenes/ui/TitleScreen.gd— the "Playing as" chip, profile menu, owner-filtered picker, claim/reassign flows, typed delete confirmation.scenes/ui/ResetConfirmModal.gd— dual-scope typed reset with backup buttons.scenes/ui/SettingsPanel.gd— ownssettings.json(device preferences); routes the reset.scripts/ui/HudShell.gd— the one-timehud_stylemerge-write for fresh machines.scripts/core/GameManager.gd— ownscurrent_runandlineage; stamps/adoptsprofile_id; rebuildsmaw_fronts[0]from legacy saves.