M8.2 — Saving & Loading¶
What you'll learn
- How to write and read a save file with
FileAccessandJSON, underuser://. - How to serialize the M8.1 source set to a dictionary — including items as recipes (base id + rarity + rolled affix values) — and rebuild it on load.
- The load order that respects M8.1's split: restore sources, then recompute derivations, then let the world reconstruct transients.
- Why
user://is the only writable location and how it differs fromres://.
How it applies
- This is where the contract from M8.1 is honored. A correct serialize/deserialize is what makes "quit and come back" work. The structure here — a flat dictionary of sources, written as JSON — is the standard, debuggable approach a small team can maintain.
- JSON is human-readable, which helps debugging and support. When a player reports a broken save, a JSON file you can open and read is far easier to diagnose than an opaque binary blob. Readability is a maintenance feature.
user://vsres://is the M1.1 distinction made load-bearing. Saves must go where an exported game can write —user://— not into the shipped, read-onlyres://. Getting this wrong works in the editor and fails in the exported build, the classic late-discovered bug.- Items-as-recipes keeps saves robust. Saving the recipe (base id + rolls) rather than a serialized object graph means saves survive the base type being re-authored and stay small — and rebuilding via the same factory that drops use means loaded items are identical to dropped ones.
Concepts¶
user:// and FileAccess¶
res:// is the shipped, read-only project filesystem. user:// is the per-user writable directory the OS
gives your game (on Windows, under %APPDATA%). Saves, logs, and settings go there. FileAccess.open(path,
mode) opens a file; you write a string and close(), or read the text back. Always check the open
succeeded — a null handle means the open failed (permissions, missing directory), which load code must
handle (M8.3).
Serialize to a dictionary, stringify to JSON¶
The clean structure: build a plain Dictionary of the M8.1 sources (numbers, strings, arrays of small
dicts), then JSON.stringify it to text and write that. JSON handles dictionaries, arrays, numbers,
strings, and bools — so your save dictionary must contain only those (no live objects, no node references).
Items become small dictionaries (their recipe); StatBlock becomes a dictionary of its fields.
Example
Serializing the run to a dictionary. Items are saved as recipes (M8.1):
# res://scripts/save_system.gd
const SAVE_PATH := "user://save_01.json"
func _serialize() -> Dictionary:
var player := get_tree().get_first_node_in_group("player")
return {
"version": 1,
"level": player.level,
"xp": player.xp,
"current_hp": player.health.current,
"base_stats": _stats_to_dict(player.base_stats),
"currency": GameState.ember,
"position": { "x": player.global_position.x, "y": player.global_position.y },
"inventory": Inventory.items.map(_item_to_recipe),
"equipment": _equipment_to_dict(),
"rng_state": GameState.rng.state,
}
func _item_to_recipe(item: ItemData) -> Dictionary:
return {
"base_id": item.base_id, # a stable id/path of the base type
"rarity": item.rarity,
"affixes": item.affixes.map(_mod_to_dict),
}
func _mod_to_dict(m: StatModifier) -> Dictionary:
return { "stat": str(m.stat), "type": m.type, "amount": m.amount }
Every value is JSON-safe (numbers, strings, arrays, dicts). version is the schema version (M8.3 uses
it for migrations). Items are recipes; the RNG's state persists future rolls (M8.1 policy).
Array.map transforms each item/modifier into its dictionary form.
Example
Writing the dictionary to user://:
func save_game() -> void:
var data := _serialize()
var json := JSON.stringify(data, "\t") # pretty-print with tabs (readable)
var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
if file == null:
push_error("save: could not open %s (err %d)" % [SAVE_PATH, FileAccess.get_open_error()])
return
file.store_string(json)
file.close()
JSON.stringify(data, "\t") pretty-prints with tab indentation so the file is human-readable.
FileAccess.open(..., WRITE) truncates and opens for writing; a null handle is logged via push_error
(M8.3 hardens this). store_string writes the text; close() flushes it.
Load: restore sources, recompute, reconstruct¶
Loading reverses serialization, in the M8.1 order: read sources into the model, then recompute derivations, then let the world rebuild transients.
Example
func load_game() -> bool:
if not FileAccess.file_exists(SAVE_PATH):
return false # no save: caller starts a new game
var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
if file == null:
push_error("load: could not open save")
return false
var text := file.get_as_text()
file.close()
var data = JSON.parse_string(text) # null on malformed JSON (M8.3)
if typeof(data) != TYPE_DICTIONARY:
push_error("load: save is not a dictionary")
return false
_apply(data)
return true
func _apply(data: Dictionary) -> void:
var player := get_tree().get_first_node_in_group("player")
player.level = int(data.get("level", 1))
player.xp = int(data.get("xp", 0))
player.base_stats = _dict_to_stats(data.get("base_stats", {}))
GameState.ember = int(data.get("currency", 0))
var pos: Dictionary = data.get("position", {})
player.global_position = Vector2(pos.get("x", 0.0), pos.get("y", 0.0))
Inventory.items = (data.get("inventory", []) as Array).map(_recipe_to_item)
_apply_equipment(data.get("equipment", {})) # equips loaded items (M7.2)
GameState.rng.state = int(data.get("rng_state", 0))
player.recompute_stats() # DERIVE: rebuild stats from base + equipped
player.health.current = clampi(int(data.get("current_hp", 1)), 0, player.derived.max_health)
Inventory.changed.emit()
Equipment.changed.emit()
Note the order: sources are read in (data.get(..., default) tolerates a missing field — M8.3's
forward/backward compatibility); then recompute_stats() rebuilds the derived stats (never read from
file); then current_hp is clamped to the recomputed max (M8.1's HP edge case); finally the world's
transients reconstruct on their own (enemies respawn, the player stands still). _recipe_to_item
rebuilds each item from its base id and rolled affixes via the same factory drops use, so a loaded item
is identical to a freshly dropped one.
Rebuilding items from recipes¶
_recipe_to_item is the inverse of _item_to_recipe: look up the base type by base_id, deep-duplicate
it (M6.1), set the saved rarity, and rebuild each affix StatModifier from its saved fields. Because it
uses the same base types and modifier structures as the drop path, the reconstructed item is byte-for-byte
the item the player had — the state-coverage guarantee from M8.1.
When to save¶
Common triggers: on quit, on a timer (autosave), at checkpoints, or on demand. This chapter saves on quit and on demand; M8.3 adds autosave-with-debounce concerns. Avoid saving every frame (slow, wears storage); save on meaningful events.
Walkthrough¶
- Create
res://scripts/save_system.gd(autoloadSaveSystem, or a node) withsave_game,load_game,_serialize,_apply, and the item/stat/equipment dict helpers. - Add a stable
base_idtoItemData(aStringNameor the base.trespath) so items can be rebuilt; maintain a registry mappingbase_id→ baseItemData(a small autoload or a folder scan). - Trigger save/load: call
SaveSystem.save_game()from a debug key and on quit (_notification(NOTIFICATION_WM_CLOSE_REQUEST)), andSaveSystem.load_game()on launch (falling back to a new game if it returns false). - Press
F5, play (gain XP, pick up and equip items, spend/earn currency), save, quit, relaunch, and load. Confirm level, XP, currency, bag contents, equipped gear, and recomputed stats all return; the world is freshly populated and the player resumes where saved. - Open
user://save_01.json(the editor'sProject → Open User Data Folderreveals the path) and read it — confirm it's human-readable JSON of exactly your M8.1 source set.
Optional sanity check
After loading, compare the player's derived damage to what it was before saving: it should match
exactly, because load restored the base stats and equipped items (sources) and recomputed the derived
stats (M5.4), rather than reading a saved derived value. Then hand-edit the JSON to change level to a
higher number, reload, and confirm the change takes — proof the file is the source of truth on load and
that it's legible enough to edit (a debugging affordance, and a reminder that M8.3 must treat the file
as untrusted input).
Self-check quiz¶
Q1 — Why must the save file be written under user:// rather than res://?
A. res:// is slower to write.
B. res:// is the shipped, read-only filesystem in an exported build (and shared across players);
user:// is the per-user writable directory — the only place an exported game can save.
C. JSON only works under user://.
D. user:// compresses files automatically.
Reveal answer
B. res:// is read-only at runtime in an export and shared, so writing there fails in the
shipped game (while seeming to work in the editor, where res:// is writable) — the classic
late-found bug. user:// is the per-user writable location for saves. A, C, D are false.
Q2 — In _apply, why is recompute_stats() called and current_hp clamped to the recomputed max, rather than reading max health and derived stats from the file?
A. To save time. B. Because derived stats and max health are derivations (M8.1) rebuilt from base + equipped items; reading them from the file risks contradicting the code, so load recomputes them and clamps the saved current HP to the fresh max. C. Because the file doesn't contain stats. D. Because clamping is required by JSON.
Reveal answer
B. Derivations are never persisted; load restores the sources (base stats, equipped items) and recomputes the derived stats and max health, then clamps the saved current HP to that fresh max (M8.1's HP edge case). Reading a saved derived value could disagree with the code after a change. A is not the reason. C is false (the file has base stats). D is fabricated.
Q3 — Why save each item as a recipe (base id + rarity + rolled affix values) instead of serializing the live ItemData object?
A. Recipes render faster. B. The recipe is compact, JSON-safe, survives the base type being re-authored, and rebuilds via the same factory as drops, so the loaded item is identical to a dropped one. C. ItemData can't be put in a dictionary. D. Recipes avoid needing the base type.
Reveal answer
B. The recipe captures the item's irreducible state in JSON-safe form and rebuilds it through the same path drops use, guaranteeing fidelity while staying small and robust to base re-authoring. A is irrelevant. C is false. D is backwards — the recipe references the base type by id to rebuild from it.
Integration question¶
Q4 — open
Trace a full save-then-load cycle for a player who is level 5, at 40% HP, with a rare sword equipped and two items in the bag. Name what is written to JSON, what is rebuilt from recipes, what is recomputed, and what reconstructs on its own — and explain how the load order enforces the M8.1 save/derive/ reconstruct split.
Reveal expected answer
Save: _serialize builds a dictionary of sources — level: 5, xp, current_hp (the actual
HP value at 40%), the base StatBlock as a field dict, currency, position, the RNG state, the
bag's two items as recipes (base_id + rarity + rolled affix values each), and the equipment map
with the rare sword's recipe in the weapon slot — then JSON.stringifys it and writes it to
user://save_01.json. Only JSON-safe values are written; no live objects, no derived stats, no
enemies. Load: load_game reads and parses the file (guarding missing/malformed, M8.3), then
_apply restores in the M8.1 order. First sources: level, xp, base stats, currency, position,
RNG state are read straight in; the bag's two items and the equipped rare sword are rebuilt from
their recipes by _recipe_to_item — looking up each base_id, deep-duplicating the base, setting
rarity, and reconstructing each affix StatModifier from its saved fields, so they're identical to
when dropped. Equipping the loaded sword (M7.2) appends its modifiers. Then derivations:
recompute_stats() (M5.4) rebuilds the derived StatBlock and max health from the base plus the now-
equipped sword's modifiers — these were never in the file. Then the saved current HP is clamped
to that recomputed max, restoring the 40%-of-max state correctly even if gear changed the max.
Finally transients reconstruct on their own: enemies respawn from spawners (M4.4), no loot lies
on the ground, and the player stands still at the saved position rather than resuming mid-action.
The load order enforces the split because each category depends on the previous: you can't
recompute derived stats until the source base stats and equipped items are restored, and you can't
clamp current HP until max health is recomputed — so restoring sources first, deriving second, and
letting transients rebuild last is not just tidy, it's the only order that produces a consistent
run, which is exactly the save/derive/reconstruct discipline M8.1 prescribed.