Skip to content

M8.2 — Saving & Loading

What you'll learn

  • How to write and read a save file with FileAccess and JSON, under user://.
  • 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 from res://.

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:// vs res:// is the M1.1 distinction made load-bearing. Saves must go where an exported game can write — user:// — not into the shipped, read-only res://. 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

  1. Create res://scripts/save_system.gd (autoload SaveSystem, or a node) with save_game, load_game, _serialize, _apply, and the item/stat/equipment dict helpers.
  2. Add a stable base_id to ItemData (a StringName or the base .tres path) so items can be rebuilt; maintain a registry mapping base_id → base ItemData (a small autoload or a folder scan).
  3. Trigger save/load: call SaveSystem.save_game() from a debug key and on quit (_notification(NOTIFICATION_WM_CLOSE_REQUEST)), and SaveSystem.load_game() on launch (falling back to a new game if it returns false).
  4. 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.
  5. Open user://save_01.json (the editor's Project → Open User Data Folder reveals 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.