Module 24: Audio (Music and Sound Effects)
6 min read
#What We Have So Far
A complete, saveable JRPG with exploration, combat, quests, and party management. But it's silent. JRPGs are defined by their music as much as their gameplay. Time to fix that.
#What We're Building This Module
Background music for each area, battle music with crossfade transitions, sound effects for attacks and menus, and volume controls via audio buses.
#Audio in Godot
Godot provides two audio player nodes:
| Node | Use Case |
|---|---|
| AudioStreamPlayer | Non-positional audio: BGM, UI sounds, fanfares |
| AudioStreamPlayer2D | Positional 2D audio: footsteps, environmental sounds |
For a JRPG, most audio is non-positional. Music plays at full volume regardless of camera position, and menu sounds don't have a source in the world.
See: AudioStreamPlayer, the non-positional audio player.
#Audio Formats
| Format | Best For | Why |
|---|---|---|
| OGG Vorbis (.ogg) | Music | Small files, good quality, supports looping |
| WAV (.wav) | Sound effects | No decode latency (plays instantly), larger files |
| MP3 (.mp3) | Music (alternative) | Widely supported but slightly worse loop support |
Import audio by placing files in your project folder. Godot auto-imports them.
#MusicManager Autoload
We want music to crossfade between tracks (not abruptly cut), survive scene changes, and remember the overworld track during battle.
Create res://autoloads/music_manager.gd:
extends Node
## Manages background music with crossfading. Autoload as MusicManager.
@onready var _player_a: AudioStreamPlayer = $PlayerA
@onready var _player_b: AudioStreamPlayer = $PlayerB
var _active_player: AudioStreamPlayer
var _current_track_path: String = ""
var _previous_track_path: String = ""
var _crossfade_duration: float = 1.0
func _ready() -> void:
_active_player = _player_a
_player_a.bus = "Music"
_player_b.bus = "Music"
func play_music(track_path: String, crossfade: bool = true) -> void:
if track_path == _current_track_path:
return
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
func stop_music(fade_duration: float = 0.5) -> void:
var tween := create_tween()
tween.tween_property(_active_player, "volume_db", -40.0, fade_duration)
tween.tween_callback(_active_player.stop)
_current_track_path = ""
func remember_track() -> void:
_previous_track_path = _current_track_path
func resume_previous_track() -> void:
if _previous_track_path:
play_music(_previous_track_path)
Create the scene res://autoloads/music_manager.tscn:
- Create a new scene with Node as root. Rename it to
MusicManager. - Add two AudioStreamPlayer children. Name them
PlayerAandPlayerB. - Attach
music_manager.gdto the root node. - Save as
res://autoloads/music_manager.tscn. - Register as autoload: Project → Project Settings → Autoload → add the
.tscnfile, name itMusicManager.
Warning: All previous autoloads used
.gdfiles. MusicManager is different because it needs AudioStreamPlayer child nodes. When registering, browse tomusic_manager.tscn, NOTmusic_manager.gd. If you register the.gdfile, the AudioStreamPlayer nodes won't exist and you'll get null reference errors.
MusicManager (Node)
├── PlayerA (AudioStreamPlayer)
└── PlayerB (AudioStreamPlayer)
#Audio Assets
You'll need audio files to test with. If you don't have music/SFX yet:
- Free music: Kenney has free audio packs, or search opengameart.org for "JRPG music."
- Placeholder: Any
.oggor.wavfile works. You can create a silent.oggfile with Audacity (Generate → Silence → Export as OGG) to test the system without audio. - Create folders
res://audio/music/andres://audio/sfx/and place your files there. - Looping: Select a music
.oggfile in the FileSystem dock, go to the Import tab, and check Loop to make it repeat. Click Reimport.
#Using MusicManager in Scenes
Each area scene plays its track in _ready():
# In willowbrook.gd
func _ready() -> void:
MusicManager.play_music("res://audio/music/town_theme.ogg")
# ... rest of setup
# In whisperwood.gd
func _ready() -> void:
MusicManager.play_music("res://audio/music/forest_theme.ogg")
# In crystal_cavern.gd
func _ready() -> void:
MusicManager.play_music("res://audio/music/dungeon_theme.ogg")
#Battle Music
Before transitioning to battle, remember the current track:
# In SceneManager.start_battle():
MusicManager.remember_track()
MusicManager.play_music("res://audio/music/battle_theme.ogg")
# In SceneManager.return_from_battle():
MusicManager.resume_previous_track()
#Sound Effects
Play any battle in Chrono Trigger with the volume off, then play it again with sound. The difference is dramatic. The sword slash, the critical hit crunch, the heal chime: these audio cues give every action weight and feedback. Sound effects are the fastest way to make a game feel polished, because the player's brain processes audio feedback faster than visual feedback. A silent menu cursor feels broken; add a tiny click and it feels responsive.
SFX are simpler: play once, no crossfading. You can either add AudioStreamPlayer nodes to scenes or create a simple SFX utility:
# Simple approach: preload and play in the script that needs it
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: when navigating buttons
- Menu select: when pressing a button
- Attack hit: when damage is dealt
- Heal: when HP is restored
- Level up: jingle on level up
- Victory fanfare: short victory theme
- Door/chest open: when interacting with objects
#Audio Buses
In Undertale, the music is so integral to the storytelling that many players want it louder than the sound effects, while others find the battle SFX distracting and want to turn them down. Without separate audio buses, the only option is a single master volume slider that controls everything at once. Separate buses for music and SFX are a baseline accessibility feature that players expect.
Audio buses let you control volume separately for music and SFX.
#Setting Up Buses
- Open the Audio tab at the bottom of the editor.
- You'll see a
Masterbus. Click Add Bus twice. - Rename the new buses to
MusicandSFX. - Both should route to
Master(the default).
Now you have three buses:
Master ← Music (BGM)
← SFX (sound effects)
#Controlling Volume
Use AudioServer to adjust bus volumes:
# Volume is in decibels. 0 = full volume, -80 = effectively silent
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Music"), -10.0)
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("SFX"), -5.0)
# Mute a bus
AudioServer.set_bus_mute(AudioServer.get_bus_index("Music"), true)
See: Audio buses, for setting up bus layout, routing, and effects.
See: AudioServer, for runtime bus control.
See: AudioBusLayout, the resource that stores bus configuration.
#Volume Settings UI
No two players listen to games the same way. Some play with headphones at night and need everything quieter; others play through speakers in a noisy room. A game without volume settings forces every player into the developer's preferred mix. It is one of the most common complaints in indie game reviews: "no volume controls."
Create res://ui/settings/settings_panel.tscn:
SettingsPanel (PanelContainer)
└── VBox (VBoxContainer)
├── MusicLabel (Label: "Music Volume")
├── MusicSlider (HSlider)
├── SFXLabel (Label: "SFX Volume")
└── SFXSlider (HSlider)
Save the script as res://ui/settings/settings_panel.gd:
extends PanelContainer
## Volume settings panel. Press Escape to close.
@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()
linear_to_db() converts a 0-1 slider value to decibels. At 0, it returns -INF (silent). At 1, it returns 0 (full volume).
Note: Volume settings are lost when the game restarts. To persist them, save the slider values to
user://settings.jsonand load them in_ready(). This is left as an exercise; the pattern is the same as Module 22's save system.
#Autoload Reference Card (Final)
| Autoload | Module | Purpose |
|---|---|---|
| SceneManager | 7 | Scene transitions with fade effects |
| InventoryManager | 12 | Item storage, add/remove, signals |
| GameManager | 20 | Game flags, world state tracking |
| QuestManager | 20 | Quest tracking, objective checking |
| PartyManager | 21 | Party roster, recruitment, stats |
| SaveManager | 22 | Save/load game state to JSON |
| MusicManager | 24 | BGM crossfading, battle music |
#What We've Learned
- AudioStreamPlayer handles non-positional audio (BGM, SFX). AudioStreamPlayer2D is for positional audio.
- OGG for music, WAV for SFX.
- MusicManager uses two players for crossfading: one fading out, one fading in.
- Audio buses (Master, Music, SFX) enable independent volume control.
linear_to_db()converts slider values (0-1) to decibels for AudioServer.- Remember/resume pattern handles battle music transitions gracefully.
- SFX play once and self-destruct via
finished.connect(queue_free).
#What You Should See
- Each area plays its own background music
- Music crossfades smoothly when transitioning between areas
- Battle music plays during combat, then the overworld track resumes
- Attack hits, menu navigation, and level-ups have sound effects
- Volume sliders control music and SFX independently
#Next Module
The game sounds alive. In Module 25: Title Screen and Game Flow, we'll build the complete game loop: title screen, new game, continue, pause menu, victory ending, and credits.