Module 22: Save and Load

9 min read

#What We Have So Far

Every game system is functional: exploration, dialogue, combat, inventory, quests, party, equipment, shops. But if the player closes the game, everything is lost.

#What We're Building This Module

A save system that writes all game state to JSON files, with three save slots, save crystals in the world, and a load flow.

#What Needs Saving?

Every autoload holds state that must persist:

AutoloadData to Save
GameManagerAll flags (Dictionary)
InventoryManagerItems array, gold
QuestManagerActive, completed, turned-in quest IDs
PartyManagerMember IDs, levels, XP, stats, equipment
SceneManagerCurrent scene path, player position

#Why JSON?

We use JSON for saves because:

  • Human-readable: you can open a save file in a text editor and debug it
  • No class coupling: JSON doesn't care about your Resource class definitions
  • Universally understood: every language and tool can read JSON
  • Simple API: Godot's JSON.stringify() and JSON.parse() handle everything

The alternative (Godot's ResourceSaver with .tres files) provides type safety but couples your saves to your class hierarchy. If you rename a Resource class, old saves break. JSON is more resilient for a tutorial scope. Also, do not load untrusted .tres or .res files as user saves; keep player saves as primitive JSON data that you validate before applying.

See: Saving games, the official guide covering multiple save approaches.

#The to_save_data() / from_save_data() Pattern

Early Pokemon games infamously had save corruption bugs because the save and load code paths were not symmetric: the game would save data in one order and try to read it back in a different order. The to_save_data() / from_save_data() pattern prevents this by making each system responsible for its own round-trip. If one method writes three fields, the other reads those same three fields. The symmetry makes it almost impossible to accidentally lose data.

Each autoload gets two methods: one to export its state as a Dictionary, one to restore it.

graph TD AutoS["Autoload State\nvariables and arrays"] DictS["Dictionary"] File["JSON File\nuser://save1.json"] DictL["Dictionary"] AutoL["Autoload State\nrestored variables and arrays"] AutoS -->|to_save_data| DictS DictS -->|JSON.stringify| File File -->|JSON.parse| DictL DictL -->|from_save_data| AutoL style AutoS fill:#3498db,color:#fff style AutoL fill:#3498db,color:#fff style File fill:#e67e22,color:#fff

#GameManager

func to_save_data() -> Dictionary:
    return _flags.duplicate()

func from_save_data(data: Dictionary) -> void:
    _flags = data.duplicate()

#InventoryManager

func to_save_data() -> Dictionary:
    var items_data: Array[Dictionary] = []
    for entry in _items:
        items_data.append({
            item_id = entry.item.id,
            item_path = entry.item.resource_path,
            count = entry.count,
        })
    return {gold = gold, items = items_data}

func from_save_data(data: Dictionary) -> void:
    gold = int(data.get("gold", 0))
    _items.clear()
    for entry in data.get("items", []):
        var item: ItemData = load(entry.item_path) as ItemData
        if item:
            _items.append({item = item, count = int(entry.get("count", 1))})
    inventory_changed.emit()
    gold_changed.emit(gold)

#PartyManager

func to_save_data() -> Dictionary:
    var members_data: Array[Dictionary] = []
    for member in members:
        members_data.append({
            id = member.id,
            path = member.resource_path,
            level = member.level,
            current_xp = member.current_xp,
            max_hp = member.max_hp,
            max_mp = member.max_mp,
            attack = member.attack,
            defense = member.defense,
            speed = member.speed,
            current_hp = member.current_hp,
            current_mp = member.current_mp,
            weapon_path = member.equipped_weapon.resource_path if member.equipped_weapon else "",
            armor_path = member.equipped_armor.resource_path if member.equipped_armor else "",
            accessory_path = member.equipped_accessory.resource_path if member.equipped_accessory else "",
        })
    return {members = members_data}

func from_save_data(data: Dictionary) -> void:
    members.clear()
    for entry in data.get("members", []):
        var character := ResourceLoader.load(
            entry.path, "", ResourceLoader.CACHE_MODE_IGNORE,
        ) as CharacterData
        if character:
            character.level = int(entry.get("level", 1))
            character.current_xp = int(entry.get("current_xp", 0))
            character.max_hp = int(entry.get("max_hp", character.max_hp))
            character.max_mp = int(entry.get("max_mp", character.max_mp))
            character.attack = int(entry.get("attack", character.attack))
            character.defense = int(entry.get("defense", character.defense))
            character.speed = int(entry.get("speed", character.speed))
            character.current_hp = int(entry.get("current_hp", character.max_hp))
            character.current_mp = int(entry.get("current_mp", character.max_mp))
            var weapon_path: String = entry.get("weapon_path", "")
            if weapon_path:
                character.equipped_weapon = load(weapon_path) as ItemData
            var armor_path: String = entry.get("armor_path", "")
            if armor_path:
                character.equipped_armor = load(armor_path) as ItemData
            var accessory_path: String = entry.get("accessory_path", "")
            if accessory_path:
                character.equipped_accessory = load(accessory_path) as ItemData
            members.append(character)

This is our first use of ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE). The third argument tells Godot to bypass the usual cached copy and read a fresh CharacterData resource from disk. That matters because party members are mutable runtime objects now: they level up, change equipment, and lose HP. Loading a fresh base definition and then applying the saved state on top keeps the save/load boundary clean.

#QuestManager

func to_save_data() -> Dictionary:
    return {
        active = _active_quests.map(func(q: QuestData) -> String: return q.resource_path),
        completed = _completed_quests.map(func(q: QuestData) -> String: return q.resource_path),
        turned_in = _turned_in_quests.map(func(q: QuestData) -> String: return q.resource_path),
    }

func from_save_data(data: Dictionary) -> void:
    _active_quests.clear()
    _completed_quests.clear()
    _turned_in_quests.clear()
    for path in data.get("active", []):
        var q: QuestData = load(path) as QuestData
        if q: _active_quests.append(q)
    for path in data.get("completed", []):
        var q: QuestData = load(path) as QuestData
        if q: _completed_quests.append(q)
    for path in data.get("turned_in", []):
        var q: QuestData = load(path) as QuestData
        if q: _turned_in_quests.append(q)

#Persistent World Objects

Module 16 introduced chest_id on treasure chests. Now we make that ID meaningful. Small one-shot world state can live in GameManager flags because flags are already saved and loaded.

Use stable scene keys and object IDs:

const SCENE_KEY := "crystal_cavern"


func _world_flag(object_id: String, state: String) -> String:
    return GameManager.make_world_flag(SCENE_KEY, object_id, state)

For a chest, replace the Module 16 script with this complete final version. Keeping the original field names (item_count, is_opened, _open()) avoids a partial-patch mismatch:

extends StaticBody2D
## A treasure chest that persists opened state through GameManager flags.

signal opened

@export var scene_key: String = "crystal_cavern"
@export var chest_id: String = ""
@export var item: ItemData
@export var item_count: int = 1

var is_opened: bool = false
var _player_in_range: bool = false

@onready var _sprite: Sprite2D = $Sprite
@onready var _prompt: Label = $InteractionPrompt
@onready var _zone: Area2D = $InteractionZone


func _ready() -> void:
    _zone.body_entered.connect(_on_body_entered)
    _zone.body_exited.connect(_on_body_exited)
    _prompt.visible = false
    if chest_id.is_empty():
        push_warning("TreasureChest needs a stable chest_id for save/load.")
    is_opened = GameManager.has_flag(_world_flag(chest_id, "opened"))
    _refresh_sprite()


func _unhandled_input(event: InputEvent) -> void:
    if not _player_in_range or is_opened:
        return
    if event.is_action_pressed("interact"):
        _open()
        get_viewport().set_input_as_handled()


func _open() -> void:
    if is_opened:
        return

    is_opened = true
    GameManager.set_flag(_world_flag(chest_id, "opened"))
    if item:
        InventoryManager.add_item(item, item_count)
        print("Found: " + item.display_name + " x" + str(item_count))
    _refresh_sprite()
    opened.emit()


func _refresh_sprite() -> void:
    _prompt.visible = _player_in_range and not is_opened
    if is_opened:
        # Swap to your open-chest texture or frame here.
        # _sprite.frame = 1
        pass


func _world_flag(object_id: String, state: String) -> String:
    return GameManager.make_world_flag(scene_key, object_id, state)


func _on_body_entered(body: Node2D) -> void:
    if body.is_in_group("player"):
        _player_in_range = true
        _refresh_sprite()


func _on_body_exited(body: Node2D) -> void:
    if body.is_in_group("player"):
        _player_in_range = false
        _refresh_sprite()

Use the same pattern for other one-shot objects:

ObjectSuggested flag
Opened chestworld.crystal_cavern.<chest_id>.opened
Defeated boss triggerworld.crystal_cavern.crystal_guardian.defeated
Unlocked boss doorworld.crystal_cavern.boss_door.unlocked
Removed pickupworld.whisperwood.pendant_chest.opened

Engine Gotcha: String IDs are part of your save format. Renaming a scene key or chest_id after players have saves makes the old save look like the object was never opened. For a small tutorial game, stable IDs are enough. Larger games usually add migration code.

#The SaveManager

Create res://autoloads/save_manager.gd and register it as an autoload (Project → Project Settings → Autoload → add save_manager.gd as SaveManager). Unlike the other autoloads, SaveManager has no visible nodes; it's pure logic. We make it an autoload because save/load needs a stable runtime owner that survives scene changes, coordinates other autoloads, and can restore the scene after change_scene_to_file() completes:

Note: await is a GDScript coroutine feature. The reason SaveManager is an autoload is architectural: it owns persistent save-slot behavior and orchestrates scene changes from a node that is not freed when gameplay scenes are replaced.

graph TD Crystal["Save Crystal opens save slot UI"] Save["SaveManager.save_game(slot)"] Gather["Call to_save_data() on\nGameManager, InventoryManager,\nPartyManager, QuestManager"] Write["JSON.stringify()\nwrite to user://saves/"] Load["SaveManager.load_game(slot)"] Read["Read file\nJSON.parse()"] Restore["Call from_save_data() on each manager"] Scene["change_scene_to_file()\nawait scene_changed"] Position["Restore player position"] Crystal --> Save Save --> Gather Gather --> Write Write -->|"later"| Load Load --> Read Read --> Restore Restore --> Scene Scene --> Position style Save fill:#3498db,color:#fff style Load fill:#8e44ad,color:#fff style Write fill:#e67e22,color:#fff style Position fill:#2ecc71,color:#fff
extends Node
## Handles saving and loading game state to JSON files.
## Registered as autoload, accessible as SaveManager.

const SAVE_DIR := "user://saves/"
const MAX_SLOTS := 3


func save_game(slot: int) -> bool:
    # Creates the save directory (and any parent directories) if it doesn't
    # exist yet. Safe to call even if the directory already exists.
    DirAccess.make_dir_recursive_absolute(SAVE_DIR)

    var save_data: Dictionary = {
        version = 1,
        timestamp = Time.get_datetime_string_from_system(),
        scene_path = "",
        player_position = {x = 0.0, y = 0.0},
        game_flags = {},
        inventory = {},
        party = {},
        quests = {},
    }

    # Gather state from autoloads
    save_data.game_flags = GameManager.to_save_data()
    save_data.inventory = InventoryManager.to_save_data()
    save_data.party = PartyManager.to_save_data()
    save_data.quests = QuestManager.to_save_data()

    # Scene and player position
    var tree := Engine.get_main_loop() as SceneTree
    if tree and tree.current_scene:
        save_data.scene_path = tree.current_scene.scene_file_path
    var player := tree.get_first_node_in_group("player") if tree else null
    if player:
        save_data.player_position = {
            x = player.global_position.x,
            y = player.global_position.y,
        }

    # Write to file
    var path := SAVE_DIR + "save_" + str(slot) + ".json"
    var file := FileAccess.open(path, FileAccess.WRITE)
    if not file:
        push_error("SaveManager: failed to open " + path + " for writing")
        return false

    var json_string := JSON.stringify(save_data, "\t")
    file.store_string(json_string)
    file.close()
    print("Game saved to slot " + str(slot))
    return true


func load_game(slot: int) -> bool:
    var path := SAVE_DIR + "save_" + str(slot) + ".json"

    if not FileAccess.file_exists(path):
        push_error("SaveManager: save file not found: " + path)
        return false

    var file := FileAccess.open(path, FileAccess.READ)
    if not file:
        push_error("SaveManager: failed to open " + path)
        return false

    var json_string := file.get_as_text()
    file.close()

    # Godot's JSON class works in two steps: json.parse() attempts to parse
    # the string (returns OK on success), then json.data holds the result as
    # a Variant. A valid JSON root can be an array, number, string, bool, null,
    # or object, so check that it is a Dictionary before using it as save data.
    var json := JSON.new()
    var error := json.parse(json_string)
    if error != OK:
        push_error("SaveManager: JSON parse error: " + json.get_error_message())
        return false

    var parsed: Variant = json.data
    if not parsed is Dictionary:
        push_error("SaveManager: save root must be a Dictionary.")
        return false

    var save_data: Dictionary = parsed

    # Restore state to autoloads
    GameManager.from_save_data(save_data.get("game_flags", {}))
    InventoryManager.from_save_data(save_data.get("inventory", {}))
    PartyManager.from_save_data(save_data.get("party", {}))
    QuestManager.from_save_data(save_data.get("quests", {}))

    # Load the saved scene
    var scene_path: String = save_data.get("scene_path", "")
    if scene_path:
        var tree := Engine.get_main_loop() as SceneTree
        tree.change_scene_to_file(scene_path)
        # change_scene_to_file() is deferred. Wait for the tree to update.
        await tree.scene_changed

        # Restore player position
        var pos_data: Dictionary = save_data.get("player_position", {})
        var player := tree.get_first_node_in_group("player")
        if player:
            player.global_position = Vector2(
                pos_data.get("x", 0.0),
                pos_data.get("y", 0.0),
            )

    print("Game loaded from slot " + str(slot))
    return true


func get_slot_info(slot: int) -> Dictionary:
    var path := SAVE_DIR + "save_" + str(slot) + ".json"
    if not FileAccess.file_exists(path):
        return {}

    var file := FileAccess.open(path, FileAccess.READ)
    if not file:
        return {}

    var json := JSON.new()
    if json.parse(file.get_as_text()) != OK:
        return {}
    file.close()

    var parsed: Variant = json.data
    if not parsed is Dictionary:
        return {}

    var data: Dictionary = parsed
    return {
        timestamp = data.get("timestamp", ""),
        scene_path = data.get("scene_path", ""),
    }


func slot_exists(slot: int) -> bool:
    return FileAccess.file_exists(SAVE_DIR + "save_" + str(slot) + ".json")

See: FileAccess, for reading and writing files.

See: Data paths. user:// is the platform-specific writable directory for save data.

See: JSON, for parsing and generating JSON.

#Wiring the Save Crystal

Module 16's save crystal only printed a message. Once SaveManager exists, update that placeholder to open the slot dialog below. A direct SaveManager.save_game(1) call is fine as a temporary smoke test while wiring the autoload, but it should not be the final tutorial flow.

#Save Slot Selection UI

Final Fantasy games have used three save slots since the original NES cartridge, and the reason hasn't changed: players want to save before a risky boss fight without losing their earlier progress, and families sharing a console need separate saves. Multiple slots also let the player experiment: save before a branching choice, try one path, reload, try the other.

Rather than hardcoding slot 1, build a simple selection dialog. Create res://ui/save_slot_dialog/save_slot_dialog.tscn:

SaveSlotDialog (PanelContainer)
└── VBox (VBoxContainer)
    ├── TitleLabel (Label: "Choose a Slot")
    ├── Slot1Button (Button)
    ├── Slot2Button (Button)
    ├── Slot3Button (Button)
    └── CancelButton (Button: "Cancel")
extends PanelContainer
## A 3-slot save/load selection dialog.

signal slot_selected(slot: int)

@onready var _buttons: Array[Button] = [
    $VBox/Slot1Button,
    $VBox/Slot2Button,
    $VBox/Slot3Button,
]
@onready var _cancel_btn: Button = $VBox/CancelButton


func _ready() -> void:
    for i in range(_buttons.size()):
        _buttons[i].pressed.connect(_on_slot_pressed.bind(i + 1))
    _cancel_btn.pressed.connect(func() -> void: slot_selected.emit(0))
    refresh()
    _buttons[0].grab_focus()


func _on_slot_pressed(slot: int) -> void:
    slot_selected.emit(slot)


func refresh() -> void:
    for i in range(_buttons.size()):
        var slot_num: int = i + 1
        var info: Dictionary = SaveManager.get_slot_info(slot_num)
        if info.is_empty():
            _buttons[i].text = "Slot " + str(slot_num) + ": Empty"
        else:
            var scene_label: String = info.get("scene_path", "").get_file().get_basename().capitalize()
            _buttons[i].text = "Slot " + str(slot_num) + ": " + scene_label

Wire this into the save crystal:

func _activate() -> void:
    var dialog: PanelContainer = preload("res://ui/save_slot_dialog/save_slot_dialog.tscn").instantiate()
    get_tree().current_scene.add_child(dialog)
    var slot: int = await dialog.slot_selected
    dialog.queue_free()
    if slot == 0:
        return
    SaveManager.save_game(slot)
    print("Saved to slot " + str(slot) + "!")

And the title screen's Continue button:

func _on_continue() -> void:
    var dialog: PanelContainer = preload("res://ui/save_slot_dialog/save_slot_dialog.tscn").instantiate()
    add_child(dialog)
    var slot: int = await dialog.slot_selected
    dialog.queue_free()
    if slot == 0:
        return
    SaveManager.load_game(slot)

#Error Handling

JSON files can be corrupted (incomplete write, manual editing). Always validate:

if json.parse(json_string) != OK:
    push_error("Corrupt save file: " + json.get_error_message())
    return false

var parsed: Variant = json.data
if not parsed is Dictionary:
    push_error("Save data is not a Dictionary")
    return false

var save_data: Dictionary = parsed
if not save_data.has("version"):
    push_error("Save data missing version field")
    return false

Autoload reference card (updated):

AutoloadModulePurpose
SceneManager7Scene transitions with fade effects
InventoryManager12Item storage, add/remove, signals
GameManager20Game flags, world state tracking
QuestManager20Quest tracking, objective checking
PartyManager21Party roster, recruitment, stats
SaveManager22Save/load game state to JSON

Note: load_game() uses tree.change_scene_to_file() directly instead of SceneManager.change_scene(). This is intentional. The save system needs to bypass SceneManager's spawn point logic and instead restore the exact player position from the save file. If you want the fade effect, you could call SceneManager._anim_player.play("fade_out") before loading and fade_in after.

#On Save Schema Design

As your game grows, you'll add new things that need saving (new autoloads, new systems, new character fields). Each addition means updating to_save_data() and from_save_data() for the affected objects. This works, but it's worth knowing the alternative.

Some RPG architectures use a declarative save schema, a single data structure that describes what to save, separate from how to save it. Instead of each autoload knowing how to serialize itself, a central schema says "from PartyManager, save these fields; from InventoryManager, save these fields." The save system walks the schema, extracts the data, and writes it. Loading walks the same schema in reverse.

The advantage: adding a new saveable field means adding one line to the schema, not editing the autoload. The disadvantage: more upfront complexity.

Our approach (to_save_data() per autoload) is the right call for Crystal Saga's scope. Each autoload owns its own serialization, which is easy to understand and debug. But if you build a larger RPG with 15+ autoloads and hundreds of saveable fields, consider consolidating into a schema.

Another thing to plan for: save migration. When you add a new field (say, a reputation system), old save files won't have it. Your from_save_data() methods should always use .get("key", default_value) rather than direct dictionary access. This way, loading an old save that lacks the reputation key gracefully falls back to the default instead of crashing.

Here is a concrete migration hook you can grow later:

func _migrate_save_data(save_data: Dictionary) -> Dictionary:
    var version: int = save_data.get("version", 1)
    if version < 2:
        save_data["settings"] = save_data.get("settings", {})
        save_data["version"] = 2
    return save_data

Call this after parsing and validating the JSON root, before restoring autoload state. For Crystal Saga, the default values are enough; the important habit is that version upgrades happen in one explicit place.

#Engineering Contract

  • Global state: SaveManager orchestrates serialization; each autoload owns its own save fragment.
  • Public surface: save_game(slot), load_game(slot), slot_exists(slot), get_slot_info(slot), and SaveSlotDialog's slot_selected.
  • Invariant: Save and load schemas are symmetric, versioned, and validated before restore.
  • Failure behavior: Missing/corrupt files, non-Dictionary JSON roots, and slot cancel return cleanly.
  • Copy semantics: Save data is plain Dictionaries/Arrays; mutable Resources are restored from paths, with cache bypass where fresh runtime copies matter.

#Engine Gotcha

JSON parsing returns a Variant root. Even valid JSON might be an array or string, so check parsed is Dictionary before treating it as a save file.

The JSON specification has one generic number type. Godot can parse those values back into Variants, but when restoring into typed integer fields such as gold, level, HP, or item counts, cast with int(...) at the load boundary.

#What We've Learned

  • JSON is the save format: human-readable, no class coupling, simple API.
  • to_save_data() / from_save_data() on each autoload exports/imports state as Dictionaries.
  • user:// is the writable save directory; FileAccess handles file I/O.
  • Save crystals trigger the save flow; load_game() is designed to be reused by UI flows like the title screen's Continue button in Module 25.
  • Save slot cancellation returns slot 0, so callers can return cleanly instead of waiting forever on a separate cancel signal.
  • World-object state such as opened chests and one-shot bosses can live in saved GameManager flags when each object has a stable scene key and object ID.
  • Resources are referenced by path in saves (resource_path), not by value. For mutable party members, ResourceLoader.CACHE_MODE_IGNORE rebuilds a fresh runtime copy from the .tres definition before saved state is applied.
  • Always validate JSON before using it. Corrupt saves shouldn't crash the game.
  • Use .get("key", default) for future-proof save loading; old saves missing new fields won't crash.

#What You Should See

  • Interacting with the save crystal writes a JSON file to user://saves/
  • Loading restores the player's position, inventory, quests, party, flags, and one-shot world-object state stored in GameManager
  • The game continues exactly where you left off

#Next Module

The game is fully playable and saveable. In Module 24: Audio, we'll add background music, sound effects, and a volume settings system, bringing sound to Crystal Saga.