Module 27: Part VI Review and Cheat Sheet

15 min read

This module is a reference companion for Part VI (Modules 24-26) and a capstone for the entire tutorial series. Use it as a quick-lookup sheet when you need to remember how something works, or read it straight through as a review of everything we built.

#Part VI in Review

Part VI was about finishing the game. We weren't adding mechanics or building systems. We were taking twenty-plus modules of existing work and making it feel complete.

Module 24 tackled audio, the change that probably does the most for how the game feels. A silent game feels like a tech demo. Add music and sound effects and it starts feeling like a real game. We built a MusicManager autoload with crossfading, set up audio buses for independent volume control, added SFX that play and self-destruct, and wired volume sliders into a settings panel. Module 25 closed the game loop. We built the title screen, the pause menu, the game over screen, the victory ending, and the credits. Every path through the game now loops back to the title screen. There are no dead ends.

Module 26 was the finish line: a full playtesting walkthrough, a troubleshooting guide for the bugs you will encounter, performance advice, export instructions, and a roadmap for where to take Crystal Saga next. That's the finish line.

#Module 24: Audio (Music and Sound Effects)

  • Built a MusicManager autoload with two AudioStreamPlayers for seamless crossfading between tracks
  • Learned the difference between AudioStreamPlayer (non-positional: BGM, UI sounds) and AudioStreamPlayer2D (positional: footsteps, environmental audio)
  • Set up audio buses (Master, Music, SFX) for independent volume control and used linear_to_db() to convert slider values to decibels
  • Implemented the remember/resume pattern for battle music: save the overworld track before combat, restore it after
  • Created self-destructing SFX players using finished.connect(queue_free) for one-shot sounds

#Module 25: Title Screen and Game Flow

  • Built a title screen with New Game (fresh state initialization), Continue (save slot loading), and Settings (volume controls)
  • Created a pause menu as an autoload using process_mode = ALWAYS and get_tree().paused to work while the game tree is frozen
  • Implemented Game Over and Victory Ending screens that replaced placeholder defeat/victory behavior from earlier modules
  • Built scrolling credits using a Tween on the label's Y position
  • Completed the game loop: every path through the game (victory, defeat, quit) returns to the title screen

#Module 26: Finish Line (Polish, Export, and Next Steps)

  • Walked through a full playtesting checklist covering every system from title screen to credits
  • Catalogued common bugs (player walking through walls, null references during scene changes, dialogue not advancing) with concrete fixes
  • Covered performance basics: removing unused _process() methods, caching with @onready, object pooling
  • Learned to export the game as a standalone executable using export templates and presets
  • Mapped out extension points for the future: status effects, elemental weaknesses, limit breaks, procedural dungeons, mobile export

#Key Concepts

ConceptWhat It IsWhy It MattersFirst Seen
AudioStreamPlayerNode that plays audio without spatial positioningAll BGM, UI sounds, and fanfares use thisModule 24
AudioStreamPlayer2DNode that plays audio with 2D positional falloffEnvironmental sounds that get louder/quieter based on distanceModule 24
Audio BusA mixing channel that groups audio streams for shared volume/effectsLets players control music and SFX volumes independentlyModule 24
CrossfadingBlending one audio track out while another blends inPrevents jarring cuts when the player moves between areasModule 24
linear_to_db()Built-in function converting a 0.0-1.0 range to decibelsSliders use linear values but AudioServer expects decibelsModule 24
process_modePer-node setting controlling whether a node runs while the tree is pausedThe pause menu must process input even when everything else is frozenModule 25
get_tree().pausedGlobal pause toggle for the scene treeStops all gameplay when the pause menu opensModule 25
Game LoopThe complete flow from launch to credits and backEvery game needs a way in, a way through, and a way back to the startModule 25
Export TemplatesPlatform-specific build templates Godot uses to create executablesRequired to build a standalone application from your projectModule 26
Export PresetConfiguration specifying platform, output path, and build optionsEach target platform (Windows, macOS, Linux) gets its own presetModule 26

#Cheat Sheet

#Audio System Setup

Use AudioStreamPlayer for anything the player hears at full volume regardless of position (BGM, menu sounds, victory fanfares). Use AudioStreamPlayer2D for sounds that exist in the game world (a river, a blacksmith's hammer, footsteps on gravel).

# Non-positional: background music, UI bleeps
var bgm_player := AudioStreamPlayer.new()
bgm_player.bus = "Music"
add_child(bgm_player)

# Positional: a waterfall that gets louder as you approach
var waterfall := AudioStreamPlayer2D.new()
waterfall.bus = "SFX"
waterfall.max_distance = 300.0  # audible within 300 pixels
waterfall.stream = preload("res://audio/sfx/waterfall_loop.ogg")
add_child(waterfall)

Format choices: OGG Vorbis for music (small files, loop support), WAV for sound effects (no decode latency, plays instantly).

See: AudioStreamPlayer | AudioStreamPlayer2D

#Background Music (BGM)

The MusicManager autoload uses two AudioStreamPlayers (A and B) to crossfade between tracks. When a new track starts, one player fades out while the other fades in.

# In MusicManager autoload (music_manager.gd):
func play_music(track_path: String, crossfade: bool = true) -> void:
    if track_path == _current_track_path:
        return  # Already playing this track

    var stream: AudioStream = load(track_path) as AudioStream
    if not stream:
        push_error("MusicManager: failed to load " + track_path)
        return

    _current_track_path = track_path

    if crossfade and _active_player.playing:
        _crossfade_to(stream)
    else:
        _active_player.stream = stream
        _active_player.volume_db = 0.0
        _active_player.play()


func _crossfade_to(new_stream: AudioStream) -> void:
    var old_player := _active_player
    var new_player := _player_b if _active_player == _player_a else _player_a

    new_player.stream = new_stream
    new_player.volume_db = -40.0
    new_player.play()

    var tween := create_tween()
    tween.set_parallel(true)
    tween.tween_property(old_player, "volume_db", -40.0, _crossfade_duration)
    tween.tween_property(new_player, "volume_db", 0.0, _crossfade_duration)
    tween.chain().tween_callback(old_player.stop)

    _active_player = new_player

Each area scene calls MusicManager.play_music() in its _ready():

func _ready() -> void:
    MusicManager.play_music("res://audio/music/town_theme.ogg")

Battle music uses the remember/resume pattern:

# Before entering battle:
MusicManager.remember_track()
MusicManager.play_music("res://audio/music/battle_theme.ogg")

# After leaving battle:
MusicManager.resume_previous_track()

See: Tween | AudioStream

#Sound Effects (SFX)

SFX are fire-and-forget. Create a player, play the sound, free the node when it finishes.

const SFX_ATTACK := preload("res://audio/sfx/attack_hit.wav")
const SFX_MENU_CURSOR := preload("res://audio/sfx/menu_cursor.wav")

func _play_sfx(stream: AudioStream) -> void:
    var player := AudioStreamPlayer.new()
    player.stream = stream
    player.bus = "SFX"
    add_child(player)
    player.play()
    player.finished.connect(player.queue_free)

Common SFX to add: menu cursor, menu select, attack hit, heal, level-up jingle, victory fanfare, door/chest open.

See: AudioStreamPlayer.finished

#Audio Bus Layout

Set up three buses in the Audio tab at the bottom of the editor:

Master
├── Music   (BGM players route here)
└── SFX     (sound effect players route here)

Both Music and SFX route to Master. The Master bus controls overall volume. Each sub-bus controls its category independently.

Runtime control uses the AudioServer singleton:

# Set volume (0 dB = full, -80 dB = effectively silent)
var bus_index: int = AudioServer.get_bus_index("Music")
AudioServer.set_bus_volume_db(bus_index, -10.0)

# Mute a bus entirely
AudioServer.set_bus_mute(bus_index, true)

See: Audio buses | AudioBusLayout

#Volume Settings UI

An HSlider from 0.0 to 1.0 converted to decibels via linear_to_db():

extends PanelContainer

@onready var _music_slider: HSlider = $VBox/MusicSlider
@onready var _sfx_slider: HSlider = $VBox/SFXSlider


func _ready() -> void:
    _music_slider.min_value = 0.0
    _music_slider.max_value = 1.0
    _music_slider.step = 0.05
    _music_slider.value = 0.8
    _music_slider.value_changed.connect(_on_music_volume_changed)

    _sfx_slider.min_value = 0.0
    _sfx_slider.max_value = 1.0
    _sfx_slider.step = 0.05
    _sfx_slider.value = 0.8
    _sfx_slider.value_changed.connect(_on_sfx_volume_changed)


func _on_music_volume_changed(value: float) -> void:
    var db: float = linear_to_db(value)
    AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Music"), db)


func _on_sfx_volume_changed(value: float) -> void:
    var db: float = linear_to_db(value)
    AudioServer.set_bus_volume_db(AudioServer.get_bus_index("SFX"), db)


func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_cancel"):
        queue_free()
        get_viewport().set_input_as_handled()

At slider value 0, linear_to_db() returns -INF (silence). At 1, it returns 0 (full volume). The curve is logarithmic, matching human perception.

See: @GlobalScope.linear_to_db()

#Title Screen Architecture

TitleScreen (Control, full_rect)
├── Background (TextureRect or ColorRect)
├── Logo (Label: "Crystal Saga")
├── MenuContainer (VBoxContainer, centered)
│   ├── NewGameButton (Button: "New Game")
│   ├── ContinueButton (Button: "Continue")
│   └── SettingsButton (Button: "Settings")
└── VersionLabel (Label: "v1.0")
extends Control

@onready var _new_game_btn: Button = $MenuContainer/NewGameButton
@onready var _continue_btn: Button = $MenuContainer/ContinueButton
@onready var _settings_btn: Button = $MenuContainer/SettingsButton


func _ready() -> void:
    MusicManager.play_music("res://audio/music/title_theme.ogg")

    _new_game_btn.pressed.connect(_on_new_game)
    _continue_btn.pressed.connect(_on_continue)
    _settings_btn.pressed.connect(_on_settings)

    # Disable Continue if no saves exist
    _continue_btn.disabled = not _any_saves_exist()
    _new_game_btn.grab_focus()


func _on_new_game() -> void:
    _initialize_fresh_state()
    SceneManager.change_scene("res://scenes/willowbrook/willowbrook.tscn")


func _on_continue() -> void:
    SaveManager.load_game(1)


func _initialize_fresh_state() -> void:
    GameManager.load_flags({})
    InventoryManager.from_save_data({gold = 100, items = []})
    var potion: ItemData = load("res://data/items/potion.tres")
    if potion:
        InventoryManager.add_item(potion, 3)

    PartyManager.from_save_data({members = []})
    var aiden: CharacterData = load("res://data/characters/aiden.tres")
    if aiden:
        # duplicate() prevents cached Resource from carrying stale stats
        aiden = aiden.duplicate()
        aiden.current_hp = aiden.max_hp
        aiden.current_mp = aiden.max_mp
        aiden.current_xp = 0
        aiden.level = 1
        PartyManager.add_member(aiden)

    QuestManager.from_save_data({active = [], completed = [], turned_in = []})


func _any_saves_exist() -> bool:
    for i in range(1, SaveManager.MAX_SLOTS + 1):
        if SaveManager.slot_exists(i):
            return true
    return false

Set the title screen as the project's Main Scene in Project -> Project Settings -> General -> Application -> Run -> Main Scene.

See: Button | Control | VBoxContainer

#Game Flow State Machine

The complete game loop:

┌──────────────────────────────────────────────┐
│                TITLE SCREEN                   │
│  New Game -> Initialize fresh state           │
│  Continue -> Load save slot                   │
│  Settings -> Volume controls                  │
└──────────────┬───────────────────┬────────────┘
               |                   |
        [Fresh Start]        [Load Save]
               |                   |
          Willowbrook  <---- Restored Scene
               |
          Whisperwood (explore, random battles)
               |
         Crystal Cavern (dungeon, boss)
               |
       +-- BOSS FIGHT --+
       |                 |
    Victory           Defeat
       |                 |
    Ending           Game Over
       |                 |
    Credits        Title Screen
       |
  Title Screen

At any time during gameplay:
  Escape -> Pause Menu
    -> Resume / Inventory / Quest Log / Settings / Quit to Title
  Save Crystal -> Save Game

Every path loops back to the title screen. There are no dead ends in the flow.

#Pause Menu

The pause menu is an autoload scene (.tscn, not .gd) because it needs child nodes. It lives on a CanvasLayer at layer 50 so it renders above everything.

extends CanvasLayer
## The in-game pause menu. Autoload as PauseMenu.

var _is_open: bool = false

@onready var _background: ColorRect = $Background
@onready var _resume_btn: Button = $Background/Panel/VBox/ResumeButton
@onready var _quit_btn: Button = $Background/Panel/VBox/QuitButton


func _ready() -> void:
    _background.visible = false
    process_mode = Node.PROCESS_MODE_ALWAYS

    _resume_btn.pressed.connect(close)
    _quit_btn.pressed.connect(_quit_to_title)


func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_cancel"):
        if _is_open:
            close()
        else:
            open()
        get_viewport().set_input_as_handled()


func open() -> void:
    _is_open = true
    _background.visible = true
    get_tree().paused = true
    _resume_btn.grab_focus()


func close() -> void:
    _is_open = false
    _background.visible = false
    get_tree().paused = false


func _quit_to_title() -> void:
    close()
    SceneManager.change_scene("res://ui/title_screen/title_screen.tscn")

The two critical pieces: process_mode = Node.PROCESS_MODE_ALWAYS ensures the pause menu still receives input when the tree is paused. get_tree().paused = true freezes every other node in the game.

See: Pausing games | SceneTree.paused | Node.process_mode

#Scene Transitions with Fades

The SceneManager (built in Module 7) uses an AnimationPlayer with a ColorRect to fade to black between scenes. For simpler cases or one-off transitions, a Tween works just as well:

# Tween-based fade-to-black (alternative to AnimationPlayer approach)
func _fade_and_change(scene_path: String) -> void:
    var overlay := ColorRect.new()
    overlay.color = Color.BLACK
    overlay.modulate.a = 0.0
    overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
    add_child(overlay)

    var tween := create_tween()
    tween.tween_property(overlay, "modulate:a", 1.0, 0.5)
    await tween.finished

    get_tree().change_scene_to_file(scene_path)

The SceneManager autoload is the preferred approach because it persists across scene changes, handles spawn points, and emits transition_started / transition_finished signals that other systems can listen for.

See: SceneTree.change_scene_to_file() | AnimationPlayer | ColorRect

#Export and Distribution

  1. Install export templates: Editor -> Manage Export Templates -> Download and Install.
  2. Create a preset: Project -> Export -> Add... -> choose your platform (Windows, macOS, Linux).
  3. Build: Click "Export Project", choose an output location. Godot creates a standalone executable and a .pck file containing all game data.

The .pck file holds every resource in your project (scenes, scripts, art, audio). The executable is a thin wrapper that loads and runs the .pck. Together, they are your distributable game.

See: Exporting projects

#Polish Checklist

A reference list of polish items to consider before shipping. None of these are required to have a complete game, but each one makes the experience noticeably better:

  • Screen shake on big hits (offset the camera briefly using a Tween)
  • Damage numbers that float up and fade out
  • Button hover/focus sounds on every menu (play SFX_MENU_CURSOR on focus_entered)
  • Hit flash on sprites when taking damage (modulate white briefly)
  • Fade-in on scene load (not just fade-out on exit)
  • Victory fanfare that plays before the results screen
  • Save confirmation ("Game saved!" label that fades out after 2 seconds)
  • Accessibility: keyboard/gamepad navigation for all menus, readable font sizes, colorblind-friendly palette
  • Input prompts that match the connected device (keyboard icons vs. gamepad icons)
  • Loading indicator if any scene takes more than a fraction of a second to load

#The Complete Architecture

After 26 modules, Crystal Saga is made up of seven autoloads, five major systems, three game areas, and dozens of scripts that wire them together. Here is how everything fits.

#Autoloads (Global Singletons)

These seven nodes live at the root of the scene tree for the entire lifetime of the game. They never get freed, they survive scene changes, and any script can access them by name.

AutoloadTypeModuleResponsibility
SceneManager.tscn7Fade transitions between scenes, spawn point management
InventoryManager.gd12Item storage, gold, add/remove/use items, inventory_changed signal
GameManager.gd20Boolean flags tracking world state (quest progress, doors opened, NPCs talked to)
QuestManager.gd20Quest lifecycle (start, advance, complete, turn in), objective checking
PartyManager.gd21Party roster, member recruitment, stat access, equipment slots
SaveManager.gd22Serialize all autoload state to JSON in user://, load it back, slot management
MusicManager.tscn24BGM playback, crossfading, battle music remember/resume
PauseMenu.tscn25Global pause overlay, inventory/quest/settings access during gameplay

#Custom Resources (Data Layer)

Every piece of game content is a Resource subclass defined in GDScript and instantiated as .tres files. This is the data-driven architecture at the heart of Crystal Saga.

Resource ClassModuleWhat It Describes
ItemData9Name, description, type (consumable/equipment), stat effects
CharacterData9HP, MP, ATK, DEF, abilities, level, XP, equipment slots
NPCData10Name, dialogue lines, portrait, interaction behavior
EnemyData14Stats, loot table, XP reward, AI behavior type
AbilityData15Name, MP cost, damage formula, target type
QuestData20Title, description, objectives, rewards
EncounterData16Enemy group composition, encounter weight

#The Battle System

The battle system is the most architecturally complex part of the game. It uses a node-based state machine where each state is a child node of the BattleManager.

BattleManager (Node)
├── SetupState       -> Initializes party/enemy display, picks first turn
├── TurnStartState   -> Determines whose turn it is
├── PlayerTurnState  -> Shows action menu, waits for player choice
├── EnemyTurnState   -> Runs AI to pick an action
├── ActionState      -> Executes the chosen action (damage, heal, defend)
├── VictoryState     -> Awards XP, gold, loot; checks for final boss
├── DefeatState      -> Shows Game Over screen
└── FleeState        -> Ends battle, returns to overworld

State transitions are method calls: _change_state(next_state). Each state has enter() and exit() methods. The BattleManager also manages the turn queue, ordering combatants by speed.

#Scene Structure

Each game area follows the same pattern: a root node, a TileMapLayer for the map, entity nodes for the player and NPCs, and zone triggers for exits and encounters.

Willowbrook (Node2D)             Whisperwood (Node2D)           CrystalCavern (Node2D)
├── TileMapLayer                 ├── TileMapLayer               ├── TileMapLayer
├── Player (CharacterBody2D)     ├── Player                     ├── Player
├── NPCs/                        ├── EncounterZones/            ├── TreasureChests/
│   ├── Shopkeeper               │   ├── ForestZone1            ├── SaveCrystal
│   ├── Innkeeper                │   └── ForestZone2            ├── EncounterZones/
│   ├── Fynn                     ├── ExitToWillowbrook          ├── BossDoor
│   └── Lira                     └── ExitToCavern               ├── BossRoom
├── ExitToWhisperwood                                           └── ExitToWhisperwood
└── SpawnPoints/

#UI Layer

UI screens are Control nodes that overlay the game world. Some are instantiated on demand (dialogue box, shop, inventory), others are persistent autoloads (pause menu).

ScreenModuleTrigger
DialogueBox11NPC interaction
InventoryScreen12Pause menu or dedicated button
BattleUI14-15Entering a battle
ShopUI21Talking to a shopkeeper
QuestLog20Pause menu
SettingsPanel24Title screen or pause menu
TitleScreen25Game launch, quit-to-title
PauseMenu25Escape key during gameplay
GameOver25Party wipe
Ending / Credits25Defeating the final boss

#How Systems Connect

The real complexity of a JRPG is not any single system but the connections between them. Here is how the major systems talk to each other:

Player interacts with NPC
  -> DialogueBox displays text (Module 11)
  -> Dialogue checks GameManager flags to pick the right lines (Module 20)
  -> Dialogue may start a quest via QuestManager (Module 20)
  -> Dialogue may recruit a party member via PartyManager (Module 21)
  -> Dialogue may open the ShopUI via InventoryManager (Module 21)

Player enters encounter zone
  -> EncounterSystem rolls for a random battle (Module 16)
  -> MusicManager.remember_track() saves current BGM (Module 24)
  -> SceneManager transitions to battle scene (Module 7)
  -> BattleManager runs the fight (Modules 14-18)
  -> Victory: XP -> PartyManager, gold/loot -> InventoryManager (Module 18)
  -> MusicManager.resume_previous_track() (Module 24)
  -> SceneManager returns to overworld (Module 7)

Player uses save crystal
  -> SaveManager gathers state from all autoloads (Module 22)
  -> GameManager.get_flags(), InventoryManager.to_save_data(),
     PartyManager.to_save_data(), QuestManager.to_save_data()
  -> Writes JSON to user:// (Module 22)

Player defeats final boss
  -> VictoryState detects boss ID, sets GameManager flag (Module 25)
  -> SceneManager loads Ending scene (Module 25)
  -> Ending auto-advances to Credits (Module 25)
  -> Credits return to TitleScreen (Module 25)

#The Patterns That Scale

Three patterns recur throughout the entire architecture. If you internalize these, you can extend Crystal Saga (or build a new game) by applying them to new content:

  1. Autoload for global state. Any system that needs to survive scene changes and be accessible from anywhere becomes an autoload. The save system serializes all autoloads to capture the complete game state.

  2. Resource for data. Any piece of content that can be described as a bundle of properties (an item, a character, an enemy, a quest) becomes a custom Resource class with .tres instances. This separates data from behavior and makes content easy to author.

  3. State machine for complex flow. Any system with distinct modes (player movement: idle/walk/interact; battle: setup/turn/action/victory/defeat; quest: inactive/active/complete) becomes a state machine. Enum-based for simple cases, node-based for complex ones.

#Common Mistakes and Fixes

MistakeSymptomFix
Registering MusicManager or PauseMenu as .gd instead of .tscnNull reference errors on $PlayerA, $BackgroundRegister the .tscn file in Project Settings -> Autoload, not the .gd file
Not setting process_mode = ALWAYS on the pause menuPause menu does not respond to input when the game is pausedSet process_mode = Node.PROCESS_MODE_ALWAYS in _ready()
Calling get_tree().paused = true without unpausing on resumeGame stays frozen after closing the pause menuEnsure close() sets get_tree().paused = false
Using load() for a character Resource without duplicate()New Game carries stats from previous play sessionCall resource.duplicate() on cached Resources before modifying their properties
Slider value passed directly to set_bus_volume_db()Volume curve feels wrong (too quiet in the middle)Convert with linear_to_db(value) first; it handles the logarithmic curve
Music plays over itself when re-entering the same areaTwo copies of the same track stackedCheck track_path == _current_track_path and return early if already playing
Forgetting to check is_instance_valid() during scene transitionsCrash: "Attempting to call on freed instance"Guard node access with if is_instance_valid(node): in callbacks that may fire during or after a transition
SFX player never freed after playingOrphaned AudioStreamPlayer nodes accumulate in the scene treeConnect player.finished to player.queue_free so one-shot sounds clean up after themselves

#Official Godot Documentation

#Audio

#UI and Controls

#Scene Management and Flow

#Animation and Tweening

#Utility

#Export and Performance

#Where to Go from Here

You have built a complete JRPG: a working game with a title screen, three areas, NPCs, a battle system, quests, saves, audio, and credits. The architecture is modular: autoloads for global state, Resources for data, state machines for complex flow, signals for decoupled communication. These patterns apply outside Crystal Saga and outside Godot. State machines show up in networking code. The Resource pattern is data-driven design. Signals are the Observer pattern. What you learned here is game architecture, not just one engine's API.

The best next step is to keep working on this project. Pick one of the extension ideas from Module 26 (status effects, elemental damage, limit breaks, more party members, procedural dungeons) and build it. You already have the save/load serialization pattern, the Resource data layer, and the state machine framework. A status effect system is a new Resource class, a new array on CharacterData, and a new step in the battle turn loop. Elemental damage is a new enum on AbilityData and EnemyData plus a multiplier in the damage formula. Each one teaches you something new while reinforcing what you already know.

When you're ready for a new project, try a different genre. A platformer teaches physics and level design, a roguelike teaches procedural generation, a visual novel teaches branching narrative. The fundamentals (scenes, signals, state machines, data-driven design) carry over from everything you built here. When you get stuck: the official Godot documentation, the Godot community forums, GDQuest for structured courses, and the Godot Discord for real-time help. The community is active and helpful. Use it.

Good luck with whatever you build next.