Module 23: Part V Review and Cheat Sheet

12 min read

This module is your reference for everything covered in Part V: Progression and Persistence (Modules 20-22). Use it to review what you built, look up syntax you have forgotten, or sanity-check your implementation before moving on.

#Part V in Review

Part V connected Crystal Saga's isolated systems into a game with actual progression. Before these three modules, the player could explore, fight, and collect items, but nothing tied those actions together into a story arc, and nothing survived closing the game.

Module 20 introduced two foundational concepts: game flags and quests. Game flags gave every system in the project a shared language for tracking what has happened in the world, while the quest system built on top of those flags to create structured objectives with rewards. The trick was a reactive signal (flag_changed) that lets quest completion happen automatically when the world state changes, rather than through manual checking. This also made NPCs responsive: Fynn remembers whether you have spoken to him, whether you found his pendant, and whether you already returned it.

Module 21 added the remaining progression systems. PartyManager gave us a roster (and Lira, the first companion), the equipment system made gear meaningful by modifying effective stats, and the shop system let the player spend their hard-earned gold. These systems layered cleanly onto the Resource and autoload patterns established in earlier modules. Finally, Module 22 closed the loop by persisting every piece of game state to JSON files. The to_save_data() / from_save_data() pattern gave each autoload a clean serialization boundary, and the save slot UI gave players the classic three-slot experience. With save and load in place, Crystal Saga became a game you can actually put down and come back to.

#Module 20: The Quest System and Game Flags

  • Built the GameManager autoload: a dictionary of boolean flags (flag_name -> true/false) with a flag_changed signal that lets any system react when the world state changes.
  • Created the QuestData Resource class with objectives defined as flag names, so a quest completes automatically when all its objective flags are set.
  • Built the QuestManager autoload to track active, completed, and turned-in quests, grant rewards on turn-in, and emit signals for quest state transitions.
  • Implemented reactive NPC dialogue where characters like Fynn say different things depending on flags and quest progress, creating the illusion of a living world.
  • Added a quest log UI that lists active quests and shows per-objective checkmarks based on current flag state.

#Module 21: Party Management, Equipment, and Shops

  • Built the PartyManager autoload to manage the party roster, with signals for join/leave events and a lookup-by-ID method.
  • Implemented Lira's recruitment as a flag-gated dialogue sequence: first meeting sets a flag, second conversation triggers add_member(), and the lira_joined flag prevents re-recruitment.
  • Extended CharacterData with equipment slots (weapon, armor, accessory) and get_effective_*() methods that add equipment bonuses to base stats, wired directly into battle through BattlerData.
  • Created the ShopData Resource and shop UI: a CanvasLayer that pauses the game, lists items with prices, validates gold, and handles purchases through InventoryManager.
  • Added the innkeeper pattern: a dialogue choice that costs gold and restores HP/MP for the entire party via PartyManager.

#Module 22: Save and Load

  • Identified what needs saving by auditing every autoload for mutable state: flags, inventory, party members (including levels, stats, and equipment), quest arrays, scene path, and player position.
  • Implemented the to_save_data() / from_save_data() pattern on each autoload: export state as a plain Dictionary, import it back from one. Resources are referenced by file path, not serialized by value.
  • Built the SaveManager autoload with save_game(slot) and load_game(slot): writes JSON to user://saves/, reads it back, restores all autoload state, and changes to the saved scene with the correct player position.
  • Created a save slot selection UI with three slots that display metadata (timestamp, location) and wire into both save crystals and the title screen's Continue button.
  • Added error handling for corrupt or missing save files: JSON parse validation, Dictionary type checks, and version field verification.

#Key Concepts

ConceptWhat It IsWhy It MattersFirst Seen
Game flagA boolean key-value pair in GameManager ("pendant_found" = true)Universal state tracking that every system can read and react toModule 20
flag_changed signalEmitted by GameManager when any flag value changesEnables reactive systems: quests auto-complete, NPCs update dialogue, doors unlock, without pollingModule 20
QuestDataA Resource class defining a quest's objectives, descriptions, and rewardsData-driven quest definitions that live in .tres files and can be created in the editorModule 20
Quest state machineNOT_STARTED -> ACTIVE -> COMPLETE -> TURNED_INPrevents invalid transitions like completing a quest that was never startedModule 20
Objective flagsAn array of flag names on QuestData that map 1:1 to objectivesQuests complete automatically when all objective flags are set, no manual checking neededModule 20
Reactive dialogueNPC dialogue functions that branch on flag/quest stateMakes the world feel responsive to the player's actions without complex scriptingModule 20
PartyManagerAn autoload holding the array of CharacterData for current party membersCentralized roster that battle, UI, save, and equipment systems all referenceModule 21
Equipment slotsequipped_weapon, equipped_armor, equipped_accessory vars on CharacterDataGear modifies effective stats, creating meaningful progression from shops and lootModule 21
Effective statsget_effective_attack() = base stat + equipment bonusSeparates permanent character growth from temporary equipment bonusesModule 21
ShopDataA Resource class listing items available for purchaseData-driven shops: change inventory by editing a .tres file, no code changesModule 21
to_save_data()A method on each autoload that exports its state as a DictionaryClean serialization boundary: each system owns its own save formatModule 22
from_save_data()A method on each autoload that restores state from a DictionarySymmetric with to_save_data(), making save/load a simple round-tripModule 22
user:// pathGodot's platform-specific writable directory for user dataSave files go here because res:// is read-only in exported buildsModule 22
Save slotA numbered JSON file (save_1.json, save_2.json, save_3.json)The classic JRPG pattern: players maintain multiple save statesModule 22
Resource path referencingSaving resource_path strings instead of serializing entire ResourcesKeeps save files small and resilient; the .tres file is the source of truthModule 22

#Cheat Sheet

#Game Flags System

The GameManager autoload stores boolean flags that any system can set and check. The flag_changed signal makes the system reactive rather than poll-based.

# Setting flags
GameManager.set_flag("talked_to_elder")          # Sets to true
GameManager.set_flag("door_locked", false)        # Explicitly set to false
GameManager.clear_flag("temporary_buff")          # Same as set_flag(..., false)

# Checking flags
if GameManager.has_flag("pendant_found"):
    # Player found the pendant
    pass

var is_unlocked: bool = GameManager.get_flag("crystal_cavern_unlocked")

# Reacting to flag changes from anywhere
GameManager.flag_changed.connect(_on_flag_changed)

func _on_flag_changed(flag_name: String, value: bool) -> void:
    if flag_name == "boss_defeated" and value:
        # The boss was just defeated, update the world
        pass

# Bulk operations (used by save/load)
var all_flags: Dictionary = GameManager.get_all_flags()
GameManager.load_flags(saved_flags_dictionary)

Register GameManager as an autoload at Project -> Project Settings -> Autoload -> add res://autoloads/game_manager.gd as GameManager.

#Quest Architecture

Quests are data-driven: a QuestData Resource defines what a quest is, and QuestManager tracks its lifecycle.

# QuestData Resource (res://resources/quest_data.gd)
extends Resource
class_name QuestData

enum QuestState { NOT_STARTED, ACTIVE, COMPLETE, TURNED_IN }

@export var id: String = ""
@export var title: String = ""
@export_multiline var description: String = ""

@export_group("Objectives")
@export var objectives: Array[String] = []         # Human-readable text
@export var objective_flags: Array[String] = []     # Flag names for completion

@export_group("Rewards")
@export var xp_reward: int = 0
@export var gold_reward: int = 0
@export var reward_items: Array[ItemData] = []
@export var completion_flag: String = ""  # Flag set on turn-in
# Starting a quest
var quest: QuestData = load("res://data/quests/crystal_resonance.tres")
if quest:
    QuestManager.start_quest(quest)

# Checking quest state
if QuestManager.is_quest_active("lost_pendant"):
    pass
if QuestManager.is_quest_complete("lost_pendant"):
    pass

# Turning in a quest (grants rewards automatically)
QuestManager.turn_in_quest(quest)

# Listing quests
var active: Array[QuestData] = QuestManager.get_active_quests()
var done: Array[QuestData] = QuestManager.get_completed_quests()

# Quest signals
QuestManager.quest_started.connect(_on_quest_started)
QuestManager.quest_completed.connect(_on_quest_completed)
QuestManager.quest_turned_in.connect(_on_quest_turned_in)

Quest completion is automatic: QuestManager listens for GameManager.flag_changed and checks whether all objective_flags for each active quest are now set. When they are, it moves the quest to the completed list and emits quest_completed.

#Quest Log UI

The quest log is a PanelContainer with a list of quest buttons and a detail label.

QuestLog (PanelContainer)
└── MarginContainer
    └── VBoxContainer
        ├── QuestList (VBoxContainer)       # Buttons, one per active quest
        └── DetailLabel (RichTextLabel)      # Quest description + objectives
# Refreshing the quest list
func refresh() -> void:
    for child in _quest_list.get_children():
        child.queue_free()
    await get_tree().process_frame

    for quest in QuestManager.get_active_quests():
        var button := Button.new()
        button.text = quest.title
        button.pressed.connect(_show_detail.bind(quest))
        _quest_list.add_child(button)

# Showing objectives with checkmarks
func _show_detail(quest: QuestData) -> void:
    var text := "[b]" + quest.title + "[/b]\n\n"
    text += quest.description + "\n\n[b]Objectives:[/b]\n"
    for i in quest.objectives.size():
        var done: bool = false
        if i < quest.objective_flags.size():
            done = GameManager.has_flag(quest.objective_flags[i])
        var marker: String = "[x]" if done else "[ ]"
        text += marker + " " + quest.objectives[i] + "\n"
    _detail_label.text = text

#Reactive NPC Dialogue

NPCs check flags and quest state to choose which dialogue lines to show. The pattern is a function that returns different Array[DialogueLine] based on the current world state, checking from most-progressed to least-progressed.

func _get_fynn_dialogue() -> Array[DialogueLine]:
    # Check most-progressed state first
    if GameManager.has_flag("pendant_returned"):
        return _make_lines("Fynn", ["Thank you again for finding my pendant!"])
    elif GameManager.has_flag("pendant_found"):
        return _make_lines("Fynn", [
            "You found it! My pendant! Thank you so much!",
            "Please, take this as a reward.",
        ])
    elif GameManager.has_flag("talked_to_fynn"):
        return _make_lines("Fynn", ["Any luck finding my pendant in the Whisperwood?"])
    else:
        # First meeting: set the flag and start the quest
        GameManager.set_flag("talked_to_fynn")
        var quest: QuestData = load("res://data/quests/lost_pendant.tres")
        if quest:
            QuestManager.start_quest(quest)
        return _make_lines("Fynn", [
            "I lost something precious in the Whisperwood...",
            "A pendant, silver with a blue stone.",
            "If you find it, I'd be forever grateful.",
        ])

# Helper to create dialogue line arrays
func _make_lines(speaker: String, texts: Array[String]) -> Array[DialogueLine]:
    var lines: Array[DialogueLine] = []
    for text in texts:
        var line := DialogueLine.new()
        line.speaker_name = speaker
        line.text = text
        lines.append(line)
    return lines

The key pattern: check flags from most progressed to least progressed (post-quest, mid-quest, pre-quest, first meeting). The first branch that matches wins.

#PartyManager

The PartyManager autoload manages who is in the party. It starts with the hero and grows through recruitment events.

# Core API
PartyManager.add_member(character)          # CharacterData
PartyManager.remove_member(character)
var members: Array[CharacterData] = PartyManager.get_members()
var lira: CharacterData = PartyManager.get_member_by_id("lira")

# Signals
PartyManager.party_member_joined.connect(_on_member_joined)
PartyManager.party_member_removed.connect(_on_member_removed)

Recruitment is triggered by dialogue and gated by flags:

func _on_npc_interacted(npc: CharacterBody2D) -> void:
    # ... dialogue logic ...
    # After Lira's second conversation:
    if npc.npc_data.id == "lira" and GameManager.has_flag("talked_to_lira") \
            and not GameManager.has_flag("lira_joined"):
        _dialogue_box.dialogue_finished.connect(_recruit_lira, CONNECT_ONE_SHOT)

func _recruit_lira() -> void:
    GameManager.set_flag("lira_joined")
    var lira: CharacterData = load("res://data/characters/lira.tres")
    if lira:
        PartyManager.add_member(lira)

Building party battler data for combat:

var party_battlers: Array[BattlerData] = []
for char_data in PartyManager.get_members():
    var battler := BattlerData.new()
    battler.character_data = char_data
    battler.is_player_controlled = true
    party_battlers.append(battler)
SceneManager.start_battle({party = party_battlers, enemies = enemy_battlers})

#Equipment System

Equipment is stored directly on CharacterData and modifies effective stats.

# Equipment slots on CharacterData
var equipped_weapon: ItemData = null
var equipped_armor: ItemData = null
var equipped_accessory: ItemData = null

# Effective stat calculation
func get_effective_attack() -> int:
    var bonus: int = equipped_weapon.attack_bonus if equipped_weapon else 0
    return attack + bonus

func get_effective_defense() -> int:
    var bonus: int = equipped_armor.defense_bonus if equipped_armor else 0
    bonus += equipped_accessory.defense_bonus if equipped_accessory else 0
    return defense + bonus

func get_effective_speed() -> int:
    var bonus: int = 0
    if equipped_accessory:
        bonus += equipped_accessory.speed_bonus
    return speed + bonus

The equip/unequip flow always returns the previous item so you can put it back in inventory:

# Equip returns the item that was in that slot (or null)
func equip(item: ItemData) -> ItemData:
    var previous: ItemData = null
    match item.equip_slot:
        ItemData.EquipSlot.WEAPON:
            previous = equipped_weapon
            equipped_weapon = item
        ItemData.EquipSlot.ARMOR:
            previous = equipped_armor
            equipped_armor = item
        ItemData.EquipSlot.ACCESSORY:
            previous = equipped_accessory
            equipped_accessory = item
    return previous

# Full equip flow: equip new, remove from inventory, return old to inventory
func _equip_item_on_character(character: CharacterData, item: ItemData) -> void:
    var previous: ItemData = character.equip(item)
    InventoryManager.remove_item(item)
    if previous:
        InventoryManager.add_item(previous)

Battle integration uses effective stats:

func initialize_from_character() -> void:
    if not character_data:
        return
    current_hp = character_data.current_hp if character_data.current_hp > 0 else character_data.max_hp
    current_mp = character_data.current_mp if character_data.current_mp > 0 else character_data.max_mp
    current_attack = character_data.get_effective_attack()
    current_defense = character_data.get_effective_defense()
    current_speed = character_data.get_effective_speed()

#Shop System

Shops use a ShopData Resource and a CanvasLayer-based UI.

# ShopData Resource (res://resources/shop_data.gd)
extends Resource
class_name ShopData

@export var shop_name: String = ""
@export var items_for_sale: Array[ItemData] = []

Create .tres files in res://data/shops/ via the editor: right-click, New Resource, select ShopData, and drag item resources into the items_for_sale array.

# Opening the shop from an NPC interaction
func _on_npc_interacted(npc: CharacterBody2D) -> void:
    if npc.npc_data.id == "shopkeeper":
        var shop_data: ShopData = load("res://data/shops/willowbrook_shop.tres")
        if shop_data:
            _shop_ui.open_shop(shop_data)
            return
    # ... normal dialogue ...
# Shop UI core logic
func open_shop(shop_data: ShopData) -> void:
    _shop_data = shop_data
    visible = true
    get_tree().paused = true       # Pause the game behind the shop
    _refresh()

func close_shop() -> void:
    visible = false
    get_tree().paused = false
    shop_closed.emit()

func _buy_item(item: ItemData) -> void:
    if InventoryManager.spend_gold(item.buy_price):
        InventoryManager.add_item(item)
        _refresh()                  # Update gold display and button states

The shop UI sets process_mode = Node.PROCESS_MODE_ALWAYS so it can receive input while the game is paused. Buttons are disabled when the player cannot afford an item.

The innkeeper is a simpler variant that uses dialogue choices instead of a full shop UI:

func _handle_inn(npc: CharacterBody2D) -> void:
    var lines := _make_lines("Old Brennan", ["Rest for the night? That'll be 10 gold."])
    lines[0].choices = ["Yes (10g)", "No thanks"]
    _dialogue_box.start_dialogue(lines)
    var choice: int = await _dialogue_box.choice_made

    if choice == 0 and InventoryManager.spend_gold(10):
        for member in PartyManager.get_members():
            member.current_hp = member.max_hp
            member.current_mp = member.max_mp

#Save System Architecture

SaveManager gathers state from every autoload, serializes it as JSON, and writes it to disk. Loading reverses the process.

What gets saved:

AutoloadSaved Data
GameManagerAll flags (Dictionary of String -> bool)
InventoryManagerItems array (id, resource path, count) + gold
QuestManagerActive/completed/turned-in quest resource paths
PartyManagerMember paths, levels, XP, all stats, equipment paths
SceneManagerCurrent scene file path
(Player node)Global position (x, y)

Save file structure (user://saves/save_1.json):

{
    "version": 1,
    "timestamp": "2025-03-15T14:30:00",
    "scene_path": "res://scenes/willowbrook/willowbrook.tscn",
    "player_position": {"x": 128.0, "y": 256.0},
    "game_flags": {"talked_to_elder": true, "pendant_found": true},
    "inventory": {"gold": 150, "items": [{"item_id": "potion", "item_path": "res://data/items/potion.tres", "count": 3}]},
    "party": {"members": [{"id": "aiden", "path": "res://data/characters/aiden.tres", "level": 5, ...}]},
    "quests": {"active": ["res://data/quests/crystal_resonance.tres"], "completed": [], "turned_in": []}
}

The save flow:

# SaveManager.save_game(slot)
func save_game(slot: int) -> bool:
    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 each autoload
    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 JSON 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
    file.store_string(JSON.stringify(save_data, "\t"))
    file.close()
    return true

The load flow:

# SaveManager.load_game(slot)
func load_game(slot: int) -> bool:
    var path := SAVE_DIR + "save_" + str(slot) + ".json"
    if not FileAccess.file_exists(path):
        return false

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

    var json := JSON.new()
    if json.parse(file.get_as_text()) != OK:
        push_error("Corrupt save: " + json.get_error_message())
        return false
    file.close()

    var save_data: Dictionary = json.data

    # Restore each autoload's state
    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", {}))

    # Change to the saved scene and restore player position
    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)
        await tree.tree_changed
        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))
    return true

#The Saveable Pattern

Each autoload implements a symmetric pair of methods: to_save_data() exports state as a plain Dictionary, and from_save_data() restores it.

# GameManager -- simplest case, flags are already a Dictionary
func to_save_data() -> Dictionary:
    return _flags.duplicate()

func from_save_data(data: Dictionary) -> void:
    _flags = data.duplicate()
# InventoryManager -- items are serialized by resource path + count
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 = 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 = entry.count})
    inventory_changed.emit()
    gold_changed.emit(gold)
# QuestManager -- quests are serialized as resource path arrays
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)
    # ... same for completed and turned_in
# PartyManager -- members are serialized with all mutable stats + equipment paths
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}

The pattern principle: Resources are referenced by path, not serialized by value. load(entry.path) as CharacterData reloads the .tres base data, then saved values (level, stats, equipment) are applied on top. This keeps save files small and means editing a .tres file updates the base values for all future loads.

#Save Slots

Three save slots stored at user://saves/save_1.json through save_3.json.

# Constants in SaveManager
const SAVE_DIR := "user://saves/"
const MAX_SLOTS := 3

# Check if a slot has a save
func slot_exists(slot: int) -> bool:
    return FileAccess.file_exists(SAVE_DIR + "save_" + str(slot) + ".json")

# Get metadata for display
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 data: Dictionary = json.data
    return {
        timestamp = data.get("timestamp", ""),
        scene_path = data.get("scene_path", ""),
    }

The save slot dialog uses await to pause execution until the player picks a slot:

# In 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()
    SaveManager.save_game(slot)

# On the title screen
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()
    SaveManager.load_game(slot)

#Common Mistakes and Fixes

MistakeSymptomFix
Forgetting to register an autoload in Project SettingsIdentifier "GameManager" not declared in the current scope errorOpen Project -> Project Settings -> Autoload and add the script path with the correct name
Checking flags in the wrong order in reactive dialogueNPC always says the first-meeting line, or skips to the endCheck from most-progressed state to least-progressed: pendant_returned before pendant_found before talked_to_fynn before the else
Not setting process_mode = PROCESS_MODE_ALWAYS on shop UIShop opens but buttons do not respond to inputThe shop pauses the game with get_tree().paused = true, so the shop node itself must set process_mode = Node.PROCESS_MODE_ALWAYS in _ready()
Equipping an item without removing it from inventoryItem duplicated: it appears both equipped and in the inventoryAlways use the three-step flow: character.equip(item), InventoryManager.remove_item(item), then InventoryManager.add_item(previous) if there was a previous item
Saving Resource objects directly to JSON instead of their pathsJSON contains nested object data that cannot be parsed back into typed ResourcesSave resource_path strings. On load, call load(path) as ResourceType to get the actual Resource
Modifying _active_quests array while iterating over itQuest completion skips entries or throws errorsCollect newly completed quests into a separate array first, then process them after the iteration (as QuestManager does with newly_completed)
Missing await tree.tree_changed after change_scene_to_file()Player position restoration fails because the new scene has not loaded yetchange_scene_to_file() is deferred. await tree.tree_changed waits for the scene tree to finish changing before restoring position
Not emitting signals after from_save_data() in InventoryManagerUI displays stale data after loading a saveCall inventory_changed.emit() and gold_changed.emit(gold) at the end of from_save_data() so listeners update

#Official Godot Documentation

#Core Classes

  • Node: base class for all autoloads (GameManager, QuestManager, PartyManager, SaveManager)
  • Resource: base class for QuestData, ShopData, CharacterData, ItemData
  • SceneTree: change_scene_to_file(), paused, get_first_node_in_group(), tree_changed signal
  • Engine: get_main_loop() used in SaveManager to access the SceneTree

#File I/O and Serialization

  • FileAccess: open(), store_string(), get_as_text(), file_exists(), close()
  • DirAccess: make_dir_recursive_absolute() for creating save directories
  • JSON: stringify(), parse(), get_error_message()
  • Time: get_datetime_string_from_system() for save timestamps

#UI Classes

  • PanelContainer: used for quest log, equipment panel, save slot dialog
  • VBoxContainer: vertical layout for item lists, quest lists, slot buttons
  • MarginContainer: interior padding for panels
  • Button: quest selection, shop items, equipment slots, save slots
  • Label: gold display, character names, slot labels
  • RichTextLabel: quest detail view with BBCode formatting ([b], [/b])
  • CanvasLayer: shop UI rendered above the game world
  • Control: base class for all UI nodes, grab_focus(), visible

#Signals and Input

  • Signal: connect(), emit(), CONNECT_ONE_SHOT
  • InputEvent: is_action_pressed() for shop cancel input handling

#Key Tutorials

#GDScript

#What's Next

All the core systems are built and game state persists across sessions. In Part VI: Polish and Integration, we add music and sound effects, build the title screen with new game and continue flows, and tie everything together into a playable demo.