Skip to content

Deserialize on Launch

A milestone

M7.2 closes the most consequential loop in the textbook so far. By the end of this chapter, you will quit Blood Knight Grove, relaunch it, and find your Light total, your Honor count, your buildings, and your purchased upgrades exactly as you left them. Until M7.2, every session of the project was disposable — F5 launched a fresh game, the same as a player picking up the project for the first time. From M7.2 onward, the project has continuity: a player's hours of work persist across sessions, and the game state the textbook has carefully built becomes durable.

This is the moment Blood Knight Grove stops being a code project and starts being a save file — something a player can return to over weeks. M7.3 extends this by paying out the income earned during the player's absence; M8 polishes the surfaces a returning player sees.

What you'll learn

  • JSON.parse_string(text) — the inverse of M7.1's JSON.stringify. Returns the parsed Variant (typically a Dictionary) on success, or null on parse failure. The null-on-failure pattern is the foundation of robust load logic.
  • The "load is an _apply_state method, not a constructor" pattern. Deserialization populates fields on the existing GameState autoload rather than creating a new one — autoloads instantiate before save loading is possible, so the load path mutates the singleton in place.
  • The four failure modes of save loading and the right response to each: file missing (start fresh, no error), file unreadable (start fresh, log warning), file corrupt JSON (start fresh, log + back up the broken save), file readable but version unknown (start fresh after a louder warning, back up).
  • The version field as a routing key, not a strict equality check. v1 saves load directly; future v2+ saves run through migration functions before reaching _apply_state. The migration scaffold goes in now even though no migrations exist yet — the structural cost is low, the deferred cost of retrofitting is high.
  • Why _apply_state calls _recalculate_income() at the end: derived state (honor_multiplier, _income_rates) is not serialized, so it must be reconstructed from the loaded primary state before the player sees a single tick.

How it applies

  • Corrupt-save recovery determines support load. A player whose save fails to parse and just gets dumped into a fresh game with no explanation will write an angry support ticket within an hour. The same player whose save fails to parse, gets a "We couldn't load your previous save — your old save is preserved as savegame.broken.json and you can email support" message will (a) not panic, (b) actually email the broken save, (c) maybe get rescued. The error-handling shape of the load path is a player-experience surface, not a developer-debugging surface.
  • Cold-start time matters more than you think. Load runs in _ready of the GameState autoload, which is on the critical path of the user seeing anything. An idle game with a 5-second startup time gets bounced in beta reviews. M7.2's load is a sub-millisecond JSON parse for the schema sizes in question — but a future "load 100K history rows for stats" subsystem could blow this budget. Keep load lean; defer non-critical reads (achievements, replay history) to background tasks.
  • Migration is forever. The day you ship version 1 is the day you commit to migrating every future version through every prior version's data. A v3 save load might run _migrate_v1_to_v2 then _migrate_v2_to_v3 then _apply_state — three transformations chained. The migrations themselves are pure dictionary operations, no I/O, no side effects, no UI. M7.2 only ships v1; M7.2's structure anticipates the chain.
  • Save backups before a destructive operation. Whenever the load path detects a problem (corrupt JSON, unknown version, missing required field), copy the broken save to a .broken.json sidecar before overwriting it. The rule: never silently consume evidence. The player lost their save once; the next save shouldn't erase the forensic trail. Implementation: DirAccess.copy_absolute(SAVE_PATH, SAVE_PATH + ".broken.json") before the next write.
  • Versioning is a discipline, not a feature. v2 will happen — maybe to add a new resource, maybe to rename a field, maybe to fix a typo in a key. The cost of adding migration support after v1 ships is the cost of running migrations against every existing v1 save in the wild. The cost of having migration support from v1 is two methods (_load_save, _migrate_*) and one if-chain. Pay the small structural cost up front.

Concepts

JSON.parse_string and the null contract

JSON.parse_string is M7.1's stringify in reverse:

var parsed: Variant = JSON.parse_string(text)
if parsed == null:
    push_warning("Save parse failed; treating as fresh game")
    return

JSON.parse_string

The null sentinel is the only failure signal. There is no exception, no error code, no second-argument out-parameter. If the JSON is malformed, parse_string returns null and the caller decides what to do.

For more diagnostic info on parse failures, the longer-form JSON.parse(text) method is available — it returns an Error enum value and stores the parsed result and a line number on the JSON instance. M7.2 uses parse_string because the chapter's failure response is identical for every parse error (start fresh, back up); detailed error messages would matter only if the load path had per-error branches.

Example

A save file with a truncated tail: {"version": 1, "resources": {"li. JSON.parse_string returns null. The load path catches the null, calls _back_up_broken_save() to copy the file aside, and falls through to "start fresh" — GameState keeps its constructor-default values, which means a new run from zero. The player loses their progress (because the file was truly broken) but does not lose the evidence of what was broken.

Load runs against the existing autoload

M7.2's first counter-intuitive point: there is no "construct GameState from save data" call. GameState is an autoload; it's instantiated by Godot before any user script runs, with default field values. The save load path mutates this already-existing instance.

func _apply_state(state: Dictionary) -> void:
    _resources = state.get("resources", {"light": 0.0, "honor": 0}).duplicate()
    _lifetime_light = state.get("lifetime_light", 0.0)
    tick_multiplier = state.get("tick_multiplier", 1.0)

_apply_state

Three observations.

First, _apply_state is called from _ready of GameState itself (or from a load helper invoked at the end of _ready). The method runs once at game start — never again unless a "Load Save" menu item is added.

Second, Dictionary.get(key, default) is the right primitive. Using state["resources"] would crash on missing keys; state.get("resources", default) returns the default. Saves missing fields (older versions, hand-edited tests, partial corruption) load as the default — mostly safe, occasionally hiding bugs. The trade-off is intentional: prefer "boot to working state with some progress lost" over "boot to crash."

Third, the buildings field requires extra work: each saved count needs to be wired back to a BuildingData instance loaded from .tres. That logic is in _apply_state but elided here — the walkthrough has the full version.

Example

A v1 save with a missing tick_multiplier field (because the field was added between save creation and load). state.get("tick_multiplier", 1.0) returns 1.0. _apply_state sets tick_multiplier = 1.0, which equals "no multiplier from upgrades." The player's previously-purchased upgrades (still in purchased_upgrades) are not replayed — so they retain ownership but the multiplier value is wrong. This is a known soft failure mode; the integration question explores when to detect-and-replay vs accept-the-drift.

Four failure modes, four responses

The load path branches on four conditions. Tabulating them:

Condition Likely cause Response
File doesn't exist First launch, or player wiped save Skip load, run with defaults. No error.
File exists but FileAccess.open fails Permissions, file lock, disk error Skip load, run with defaults. Log warning.
File opens but JSON parse fails Corruption, partial write, hand-edit error Skip load, run with defaults. Log warning. Back up broken file.
JSON parses but version unrecognized Save from newer game version, or version field corrupted Skip load, run with defaults. Log louder warning. Back up broken file.

load failure modes

The shape: degrade gracefully, never crash on load. The cost of incorrect behavior on a corrupt save is "player starts fresh"; the cost of crashing on a corrupt save is "player can't play the game until they figure out how to delete the save manually." Always pick the former.

Example

A player force-quits mid-save. The save file is partially written — first 200 bytes are valid JSON prefix, last 50 are missing. On next launch: FileAccess.open succeeds (file exists, permissions fine). parse_string returns null (truncated JSON). Load path detects this, calls _back_up_broken_save() (copies to savegame.json.broken.json), and proceeds to fresh defaults. The player loses progress since their last successful save — but only the unsaved progress, because all prior save_game() calls completed atomically (M7.1 noted the atomicity gap; this is the cost of not closing it).

The version field as a routing key

The version integer in the save schema is not checked for equality with the current version; it's used to route the load to the right migration chain.

func _load_save() -> void:
    var state: Dictionary = _read_and_parse(SAVE_PATH)
    if state.is_empty():
        return
    var version: int = state.get("version", 0)
    state = _migrate_to_current(state, version)
    if state.is_empty():
        return
    _apply_state(state)

save version routing

_migrate_to_current is the migration dispatch:

func _migrate_to_current(state: Dictionary, from: int) -> Dictionary:
    if from == 0 or from > SAVE_VERSION:
        push_warning("Save version %d unknown" % from)
        _back_up_broken_save()
        return {}
    return state  # v1 == current; no migration needed

When v2 ships, the body becomes:

if from < 2:
    state = _migrate_v1_to_v2(state)
return state

The migration function is a pure transformation:

func _migrate_v1_to_v2(state: Dictionary) -> Dictionary:
    state["new_field"] = state.get("old_field", default_value)
    state.erase("old_field")
    state["version"] = 2
    return state

Example

A future v3 save loaded into a v3 game: version == 3, SAVE_VERSION == 3, _migrate_to_current returns state unchanged. A v1 save loaded into a v3 game: version == 1, SAVE_VERSION == 3, the dispatcher runs _migrate_v1_to_v2 then _migrate_v2_to_v3 then returns the upgraded state. A v3 save loaded into a v1 game (downgrade): version == 3 > SAVE_VERSION == 1, the dispatcher logs warning + backs up + returns empty. Downgrade is rejected by design — a v1 game cannot guess what fields a v3 save contains.

Why _apply_state ends with _recalculate_income

The save schema saves primary state (resource counts, building counts, owned upgrades, lifetime totals, tick_multiplier) and not derived state (_income_rates dictionary, honor_multiplier). After _apply_state populates the primary fields, the income-per-resource cache is empty and the honor multiplier hasn't been computed.

The fix is one line at the end of _apply_state:

_recalculate_income()

This call (M5.4) iterates buildings, applies the tick multiplier, applies the honor multiplier (M6.2), and writes the per-resource rates into _income_rates. After this line, the autoload is fully usable — the next tick will produce correct income, the next click will produce correct Light, the next purchase will compute correct costs.

Example

A save with 5 Initiates, tick_multiplier = 1.5, honor = 10. After _apply_state writes the primary fields, _income_rates is still {} from the autoload's default initialization. The first _on_tick after load would add 0 to every resource. The trailing _recalculate_income() call walks the 5 Initiates × their Light output × tick_multiplier 1.5 × honor_multiplier 2.0, writes the result to _income_rates["light"], and the first post-load tick produces correct income. Without the recompute call, the player observes "I loaded my save and my buildings stopped working."

Walkthrough

You'll add _load_save, _apply_state, _migrate_to_current, and _back_up_broken_save to GameState, then call _load_save from _ready.

Step 1. Open scripts/game_state.gd. Find _ready(). Add the load call as the last line of _ready:

func _ready() -> void:
    # ... existing setup ...
    _load_save()

It must be last so that any signals emitted during _apply_state reach already-connected handlers. Other autoloads' _ready may have already run (depending on autoload order); UI scenes have not yet entered the tree, so signals emitted during load that target UI will be missed unless the UI subscribes in its own _ready and pulls current state.

Step 2. Add _load_save:

func _load_save() -> void:
    if not FileAccess.file_exists(SAVE_PATH):
        return
    var file: FileAccess = FileAccess.open(SAVE_PATH, FileAccess.READ)
    if file == null:
        push_warning("Save open failed: %s" % FileAccess.get_open_error())
        return
    var text: String = file.get_as_text()
    var parsed: Variant = JSON.parse_string(text)
    if parsed == null or not (parsed is Dictionary):
        push_warning("Save parse failed; backing up and starting fresh")
        _back_up_broken_save()
        return
    var state: Dictionary = parsed
    state = _migrate_to_current(state, state.get("version", 0))
    if state.is_empty():
        return
    _apply_state(state)

The cascading early-returns: each failure mode lands in its own branch and exits cleanly. None of the branches ever leave GameState in a half-loaded state.

Step 3. Add _back_up_broken_save:

func _back_up_broken_save() -> void:
    var backup: String = SAVE_PATH + ".broken.json"
    var err: int = DirAccess.copy_absolute(SAVE_PATH, backup)
    if err != OK:
        push_warning("Save backup failed: %d" % err)

DirAccess.copy_absolute takes two user:// or res:// paths and copies the file. The error code is logged but not acted on — backup failure is a tertiary concern (the load is already failing; a failed backup adds at most one log line).

Step 4. Add _migrate_to_current:

func _migrate_to_current(state: Dictionary, from: int) -> Dictionary:
    if from <= 0 or from > SAVE_VERSION:
        push_warning("Save version %d unknown" % from)
        _back_up_broken_save()
        return {}
    return state

For now from == SAVE_VERSION == 1 is the only valid path; the function is a no-op pass-through. When v2 lands, this becomes the dispatch chain.

Step 5. Before _apply_state itself, set up the upgrade-side registry the load path needs. M5.4 introduced _building_lookup plus a public register_building(data) method on GameState; the upgrade side gets the parallel pair. Add the field declaration alongside the existing _building_lookup:

var _upgrade_lookup: Dictionary[String, UpgradeData] = {}

Add the registration method (anywhere near register_building):

func register_upgrade(data: UpgradeData) -> void:
    _upgrade_lookup[data.id] = data

Wire the caller. Open scripts/upgrade_list.gd (M4.2). In _ready(), after all_upgrades is iterated to build rows, register each:

for data in all_upgrades:
    GameState.register_upgrade(data)

The pattern mirrors BuildingList._ready from M5.4. The deserializer (next step) uses _upgrade_lookup to rebuild the typed _purchased_upgrades Dictionary from the saved Array of ids.

Step 6. Add _apply_state. Each line is a Dictionary.get with a safe default; the building and upgrade reconstruction loops walk the saved primary state and rebuild the typed runtime fields:

func _apply_state(state: Dictionary) -> void:
    _resources = state.get("resources", {"light": 0.0, "honor": 0}).duplicate()
    _lifetime_light = state.get("lifetime_light", 0.0)
    tick_multiplier = state.get("tick_multiplier", 1.0)
    _purchased_upgrades.clear()
    var saved_upgrade_ids: Array = state.get("purchased_upgrades", [])
    for id in saved_upgrade_ids:
        if _upgrade_lookup.has(id):
            _purchased_upgrades[id] = _upgrade_lookup[id]
        else:
            push_warning("Save references unknown upgrade: %s" % id)
    var building_counts: Dictionary = state.get("buildings", {})
    _buildings.clear()
    for id in building_counts:
        if not _building_lookup.has(id):
            push_warning("Save references unknown building: %s" % id)
            continue
        _buildings[id] = int(building_counts[id])
    _recalculate_income()
    for resource_name in _resources:
        resource_changed.emit(resource_name, _resources[resource_name])

Three non-obvious bits.

The upgrade reconstruction loop reads state["purchased_upgrades"] as an Array[String] (the schema M7.1 declared) and rebuilds the typed Dictionary[String, UpgradeData] by looking up each id in _upgrade_lookup. A saved id with no matching registered UpgradeData (the upgrade was removed from the project between save and load) drops with a warning rather than crashing.

The building reconstruction loop validates the id against _building_lookup and stores int(building_counts[id]) directly into _buildings. _buildings is Dictionary[String, int] (M5.3's schema); storing the int directly preserves the type contract. The int(...) cast is defensive against JSON.parse_string's float-for-whole-number behavior — see the integration question for the rationale.

The trailing resource_changed.emit loop is a UI-refresh primitive: every Label, every button, every visibility check that subscribed to resource_changed needs to know the post-load resource values. Without this, a label that subscribed in _ready would still show "Light: 0" because no signal fired between subscription and now.

Step 7. Save the file (Ctrl+S). Run the game (F5).

Step 8. Earn some Light, buy a building, prestige, save (M7.1's quit handler will save on quit). Quit.

Step 9. Re-launch. The game should boot with your post-prestige state: 1 Honor, 0 Light, 0 buildings (M6.1's _reset_run_state cleared run-scoped fields). The Honor multiplier should be active — if you have Sermons or buildings, their output should reflect the 1.1× multiplier.

Step 10. Test the corruption path. Quit. Open %APPDATA%\Godot\app_userdata\<project>\savegame.json in Notepad. Delete the closing brace and last few characters. Save. Re-launch the game. The game should:

  • Boot to fresh state (no Honor, no Light, no buildings).
  • Print the warning to the editor's Output panel.
  • Have created savegame.json.broken.json next to the (now-overwritten) savegame.json.

Step 11. Test the missing-file path. Quit. Delete savegame.json and savegame.json.broken.json. Re-launch. The game boots fresh with no warning. The first save (after any state change or 30s) creates a new clean save.

Self-check quiz

Quiz

Q1. Why does _load_save run at the end of GameState._ready?

  • A) JSON.parse_string requires the autoload's _ready to have completed.
  • B) Any signals _apply_state emits need handlers already connected. Earlier autoloads' _ready may have run, but the load call must come after GameState finishes its own constructor-equivalent setup so the autoload is in a consistent state.
  • C) FileAccess.open blocks until _ready completes.
  • D) Godot enforces this ordering.

Reveal

Correct: B. The autoload must be in a consistent post-setup state when _apply_state begins mutating fields. Connections established in _ready (signal subscriptions, lookups, registrations) need to be in place. After they are, _load_save can safely overwrite the field values they were initialized to.

  • A is wrong: parse_string has no such precondition.
  • C is wrong: FileAccess.open is a synchronous file I/O call with no relation to _ready.
  • D is wrong: Godot does not enforce internal ordering inside a single autoload's _ready. The discipline is by convention.

Q2. A v1 save loads into a v1 game. _migrate_to_current returns the state unchanged. Why does the function exist at all?

  • A) Future-proofing. v2 will ship eventually; the dispatcher is the hook future migrations attach to. Adding it now costs one method; retrofitting it later means handling every existing v1 save in the wild.
  • B) _apply_state requires its argument to pass through _migrate_to_current first.
  • C) It's a Godot convention.
  • D) It validates the save signature.

Reveal

Correct: A. The cost of having migration support from v1 is one no-op method and one if-chain branch. The cost of adding migration support after v1 ships and v2 is needed is real engineering — every existing v1 save in the wild, every test fixture, every customer-support save attachment, all need a one-time migration pass. Pay the small structural cost up front.

  • B is wrong: _apply_state works on any Dictionary that matches the schema; migration is a pre-step, not a hard precondition.
  • C is wrong: not a Godot convention; it's a software engineering pattern (see semantic versioning, schema versioning, RFC 3339).
  • D is wrong: validation would be a separate concern. Migration's job is transformation, not validation.

Q3. _apply_state ends with _recalculate_income() and a loop that emits resource_changed for every key. Why both?

  • A) Both calls are equivalent; one is redundant.
  • B) _recalculate_income reconstructs _income_rates from primary state (it's not in the save). The resource_changed loop notifies UI subscribers of post-load resource values so labels and gates can refresh — _recalculate_income doesn't emit those signals.
  • C) _recalculate_income is for the tick handler; the signal loop is for the save system.
  • D) Godot autoloads must emit a signal in _ready.

Reveal

Correct: B. They cover different concerns. _recalculate_income populates _income_rates (used by _on_tick to add per-resource amounts each tick). resource_changed notifies UI: labels showing Light counts, the prestige button's _update_visibility, the upgrade list's affordability checks. Both must run after _apply_state writes the primary fields; neither subsumes the other.

  • A is wrong: they touch different state.
  • C is wrong: _recalculate_income is part of M5.4 / M6.2 income aggregation; not specifically tick-handler-only. The signal loop is for UI notification.
  • D is wrong: no such requirement.

Integration question

M7.1 and M7.2 made an asymmetric choice about tick_multiplier: it is saved (not derived) and therefore loaded directly without replay. M7.1's integration question explored why a future M7.2 might choose to drop the saved value and replay upgrades on load. With M7.2 now written, evaluate the actual choice — is the M7.2 implementation right? What is the cost of the current "load saved value, never replay" approach? What is the upgrade path if the project later wants to replay-on-load?

Reveal

The current approach loads tick_multiplier from the save and never replays purchased upgrades. Cost:

  • Rebalance silently fails. If UpgradeData_FirstSermon.tres changes its multiplier from 1.1 to 1.2, players with old saves still get the 1.1 contribution baked into their tick_multiplier. New players (no save) get 1.2. The two cohorts diverge.
  • Removed upgrade silently retains effect. If an upgrade is deleted, its prior contribution to tick_multiplier lingers because the multiplier wasn't recomputed. The purchased_upgrades array still references the (now-missing) id, but the saved multiplier value already includes its effect.
  • Effect logic changes silently fail. If the effect-application code (apply_effect_to_state) is rewritten in a later module, old players don't pick up the new behavior on their owned upgrades.

Upgrade path to replay-on-load:

  1. Drop tick_multiplier from the save schema (bump version).
  2. In _apply_state, after setting _purchased_upgrades, loop through the array, look up each UpgradeData, call its effect-application method against GameState. This rebuilds tick_multiplier from current effect logic against current upgrade definitions.
  3. Add a _migrate_v1_to_v2 that drops the tick_multiplier field from old saves (since v2 doesn't read it).
  4. Document that effects must be deterministic and order-independent (M4.3 already enforces this).

The trade-off is about who is the source of truth: the save (cached value, frozen at save time) or the project (current effect logic, current data). M7.1+M7.2 ship with the save as source of truth; the alternative is a one-paragraph design discussion away.

Glossary

Glossary

JSON.parse_string
Godot's JSON deserializer. JSON.parse_string(text: String) -> Variant returns the parsed value on success, or null on parse failure. The null sentinel covers all parse errors (malformed JSON, unexpected end-of-input, type mismatch). Companion to JSON.stringify. Distinct from JSON.parse() which returns an Error code and stores the result on the JSON instanceparse_string is the convenience wrapper that just returns the value or null.
_apply_state
The method on GameState that takes a parsed save Dictionary and overwrites the autoload's fields with its values. Mutates the existing autoload in place — autoloads instantiate at engine boot before save data is available, so loading is a "fill in the fields" operation, not a "construct the object" operation. Uses Dictionary.get(key, default) for every field so missing keys produce safe defaults instead of crashes. Always ends by calling _recalculate_income() to reconstruct derived state.
load failure modes
The four ways save loading can fail and the four responses M7.2 implements: (1) file missing — silent fresh start; (2) FileAccess.open returns null — fresh start with warning; (3) parse_string returns null — fresh start with warning + .broken.json backup; (4) version unknown — fresh start with louder warning + .broken.json backup. The pattern: never crash, never silently destroy evidence, always boot to a playable state.
save version routing
The pattern of using the saved version field to dispatch the load path through a chain of migration functions before applying. _migrate_to_current(state, version) runs migrations from version up to SAVE_VERSION, transforming the dictionary at each step. M7.2 ships with no actual migrations because only v1 exists, but the function and the call site are in place so adding _migrate_v1_to_v2 in the future is a one-method change with no rewiring.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Walk me through what happens if Godot's autoload order ran a UI scene's _ready before GameState's _load_save. What breaks? What's the fix?` - `If I want to add a confirmation prompt 'A broken save was detected — view the backup?' on next launch after a failed load, where does that hook go?` - `Show me what _migrate_v1_to_v2 would look like if I added a new resource called "Faith" in v2. What does the migration do for an old save that has no Faith field?`