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'sJSON.stringify. Returns the parsed Variant (typically a Dictionary) on success, ornullon parse failure. The null-on-failure pattern is the foundation of robust load logic.- The "load is an
_apply_statemethod, not a constructor" pattern. Deserialization populates fields on the existingGameStateautoload 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
versionfield 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_statecalls_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.jsonand 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
_readyof theGameStateautoload, 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_v2then_migrate_v2_to_v3then_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.jsonsidecar 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:
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:
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:
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:
Add the registration method (anywhere near register_building):
Wire the caller. Open scripts/upgrade_list.gd (M4.2). In _ready(), after all_upgrades is iterated to build rows, register each:
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.jsonnext 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_stringrequires the autoload's_readyto have completed. - B) Any signals
_apply_stateemits need handlers already connected. Earlier autoloads'_readymay have run, but the load call must come afterGameStatefinishes its own constructor-equivalent setup so the autoload is in a consistent state. - C)
FileAccess.openblocks until_readycompletes. - 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_stringhas no such precondition. - C is wrong:
FileAccess.openis 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_staterequires its argument to pass through_migrate_to_currentfirst. - 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_stateworks 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_incomereconstructs_income_ratesfrom primary state (it's not in the save). Theresource_changedloop notifies UI subscribers of post-load resource values so labels and gates can refresh —_recalculate_incomedoesn't emit those signals. - C)
_recalculate_incomeis 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_incomeis 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.treschanges its multiplier from 1.1 to 1.2, players with old saves still get the 1.1 contribution baked into theirtick_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_multiplierlingers because the multiplier wasn't recomputed. Thepurchased_upgradesarray 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:
- Drop
tick_multiplierfrom the save schema (bump version). - In
_apply_state, after setting_purchased_upgrades, loop through the array, look up eachUpgradeData, call its effect-application method againstGameState. This rebuildstick_multiplierfrom current effect logic against current upgrade definitions. - Add a
_migrate_v1_to_v2that drops thetick_multiplierfield from old saves (since v2 doesn't read it). - 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) -> Variantreturns the parsed value on success, ornullon parse failure. The null sentinel covers all parse errors (malformed JSON, unexpected end-of-input, type mismatch). Companion toJSON.stringify. Distinct fromJSON.parse()which returns anErrorcode and stores the result on theJSONinstance —parse_stringis the convenience wrapper that just returns the value or null. _apply_state- The method on
GameStatethat 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. UsesDictionary.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.openreturnsnull— fresh start with warning; (3)parse_stringreturnsnull— fresh start with warning +.broken.jsonbackup; (4) version unknown — fresh start with louder warning +.broken.jsonbackup. The pattern: never crash, never silently destroy evidence, always boot to a playable state. - save version routing
- The pattern of using the saved
versionfield to dispatch the load path through a chain of migration functions before applying._migrate_to_current(state, version)runs migrations fromversionup toSAVE_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_v2in the future is a one-method change with no rewiring.