Music‐and‐Screen‐Audio - UQcsse3200/2026-studio-3 GitHub Wiki
Background music for every screen, the victory and defeat sounds, menu button clicks, the
volume settings and the pause menu music. All of it lives in AudioService, next to the sound
effects.
For loading audio, playSound, and how MusicId and SoundId map to files, see
AudioService (Sound and Music).
Related: Pause Menu, Setting Menu, Service Locator, Map to Encounter Lifecycle
GdxGame.setScreen(screen)
-> the old screen is disposed and the new one is built
-> AudioService.onScreenChanged(screen, runState)
-> pick the playlist for that screen -> play a random track from it
Every screen change goes through GdxGame.setScreen, which makes one call into AudioService
once the new screen exists. That call is the only hook into the screens. None of them have
music code of their own, so giving a new screen music only takes a line in onScreenChanged.
The pause menu is the exception, because opening it doesn't change the screen. It has its own
hook in PauseMenuFactory, covered under Pause menu music.
| Screen | What you hear |
|---|---|
| Main menu | Menu playlist, 3 tracks |
| Map | Map playlist, 3 tracks |
| Battle | Battle playlist, 3 tracks |
| Battle in an elite room | Elite track |
| Battle against the final boss | Boss track |
| Encounter that is a shop | Shop track |
| Encounter that is an event | The map music keeps going |
| Library, Card Library, Bestiary | Library playlist, 3 tracks, shared by all three |
| Victory | Music stops, victory fanfare |
| Defeat | Music stops, defeat sound |
| Pause menu, on any screen that has one | The song pauses and the pause track plays |
| Settings, Save/Load, Campfire, Elite Portal, temple screens | No change, whatever was playing keeps playing |
Moving between the library, card library and bestiary keeps the same song going, since they share a playlist. Going into the settings and back doesn't restart the menu music either.
Each kind of screen has a Playlist, a private enum inside AudioService that lists its
tracks:
private enum Playlist {
MENU(MusicId.MENU_HEARTHLIGHT, MusicId.MENU_STORYBOOK, MusicId.MENU_HEROES_GATE),
MAP(MusicId.MAP_WANDERER, MusicId.MAP_FOG, MusicId.MAP_CROSSROADS),
BATTLE(MusicId.BATTLE_CLASH, MusicId.BATTLE_IRONCLAD, MusicId.BATTLE_SKIRMISH),
LIBRARY(MusicId.LIBRARY_ARCHIVE, MusicId.LIBRARY_SPECIMENS, MusicId.LIBRARY_STUDY),
ELITE(MusicId.ELITE_BATTLE),
BOSS(MusicId.BOSS_BATTLE),
SHOP(MusicId.SHOP);
...
}next() picks a random track from the list, but never the one that playlist picked last time.
Coming back to the map after a fight always gives a different song from the one before.
startPlaylist does nothing if that playlist is already the one playing. Otherwise it stops the
current song, picks the next track and plays it on loop at the current music volume.
All the music is OGG. MP3 encoders pad the start and end of a file, which leaves a short gap every time a track loops. OGG loops cleanly.
BattleScreen and EncounterScreen are used for more than one kind of room, so the screen
type alone isn't enough. AudioService looks up the room the player walked into through
RunState, using the active node's RoomType:
-
BATTLE_SCREENplays the boss track in aFINALroom, the elite track in anELITEroom, and the battle playlist anywhere else. -
ENCOUNTERswitches to the shop track in aSHOProom. Events leave the map music playing.
GdxGame owns the RunState and passes it in with every screen change. See
Map to Encounter Lifecycle for why it outlives the screens.
Arriving on the victory or defeat screen stops the music and plays a short sound: a fanfare for
a win, and a slow falling flute line for a loss. Both go through playSound, so the sound
effects volume applies to them.
Every button on the menu screens clicks: main menu, settings, save/load, library, card library and bestiary. None of those screens were changed to do it.
Buttons in scene2d fire a ChangeEvent when pressed, and change events bubble up to the stage's
root. On those screens onScreenChanged adds one ChangeListener to the root, which plays the
click whenever the actor that changed is a Button. Sliders and select boxes fire change events
too but aren't buttons, so dragging a slider doesn't click. Checkboxes are buttons, so they do.
Gameplay screens are left out on purpose, since battles and the shop have their own sounds.
AudioService.load() loads all the audio on the main menu, map and main game screens. Some
screens register a fresh ResourceService with nothing in it: save/load, campfire, the elite
portal and the temple screens. Playing a sound there that isn't loaded would crash the game.
So everything AudioService plays for a screen goes through ensureLoaded first. If the
current ResourceService doesn't have the file, it's loaded there before it plays.
The settings panel has Master, Music and Sound Effects sliders and a Mute box. Pressing Apply
saves the settings and passes two volumes to whatever is registered as the
AudioSettingsApplier in the ServiceLocator. Both already have the master volume and mute
worked in, by getEffectiveMusicVolume() and getEffectiveSoundEffectsVolume() in
UserSettings.
AudioService registers its applyVolumes method as the applier:
- Music plays at the music volume ×
FULL_MUSIC_VOLUME(0.5). 0.5 is the level the music was balanced at against the sound effects, so 100% on the sliders sounds the same as it did before the sliders were connected. The song that's playing changes straight away. -
playSoundmultiplies the volume it's given by the sound effects volume.
ServiceLocator.registerAudioSettingsApplier applies the saved settings as soon as it's called,
so the first song of a session already starts at the saved volume.
Some screens (campfire, the elite portal and the temple screens) call ServiceLocator.clear()
when they close, which drops the applier. onScreenChanged checks on every screen change and
registers it again if it's missing.
The volume changes when you press Apply, not while dragging, because that's when the settings panel saves.
Opening the pause menu (Escape, or the pause button) pauses the song that's playing and puts on a calm pause track. Resume stops the pause track, and the song carries on from where it stopped.
PauseMenuFactory builds the pause menu for every screen that has one (map, battle, encounters
and the main game screen), so the hook goes there, next to the Save & Load listener:
uiEntity.getEvents().addListener(PauseMenuDisplay.PAUSE_EVENT, AudioService::onGamePaused);
uiEntity.getEvents().addListener(PauseMenuDisplay.RESUME_EVENT, AudioService::onGameResumed);onGamePaused keeps hold of the paused song (heldTrack) and plays the pause track.
onGameResumed stops the pause track and plays the held song again, at the current music volume
in case it was changed in the settings while paused. Pausing twice in a row doesn't stack, and
resuming when nothing was paused does nothing.
Leaving the screen from the pause menu, like Exit to Main Menu, doesn't fire the resume event.
onScreenChanged handles that instead: if the pause menu was open, it stops both the pause
track and the held song before the new screen's music starts.
classDiagram
class GdxGame {
-RunState runState
+setScreen(ScreenType)
}
class AudioService {
-Music playing$
-Playlist nowPlaying$
-Music heldTrack$
-boolean gamePaused$
-float musicVolume$
-float soundEffectsVolume$
+load()$
+playSound(SoundId, float)$
+playMusic(MusicId)$
+stopMusic()$
+setMusicVolume(float)$
+onScreenChanged(ScreenType, RunState)$
+onGamePaused()$
+onGameResumed()$
-startPlaylist(Playlist)$
-followVolumeSettings()$
-applyVolumes(float, float)$
-leavePauseMenu()$
-ensureLoaded(String, Class) boolean$
-addButtonClicks()$
}
class Playlist {
<<enumeration>>
MENU
MAP
BATTLE
LIBRARY
ELITE
BOSS
SHOP
-MusicId[] tracks
-MusicId last
-next() MusicId
}
class MusicId {
<<enumeration>>
}
class SoundId {
<<enumeration>>
}
class RunState {
+getMapGraph() MapGraph
+getActiveNodeId() Integer
}
class AudioSettingsApplier {
<<interface>>
+applyVolumes(float, float)
}
class ServiceLocator {
+registerAudioSettingsApplier(AudioSettingsApplier)$
+getAudioSettingsApplier() AudioSettingsApplier$
+getResourceService() ResourceService$
}
class UserSettings {
+set(Settings, boolean)$
+applyAudioSettings(Settings)$
}
class SettingsPanel
class PauseMenuFactory {
+attach(Entity, GdxGame) SaveLoadPanel$
}
class ResourceService
GdxGame "1" *-- "1" RunState
GdxGame ..> AudioService : onScreenChanged on every screen change
AudioService ..> RunState : reads the current room
AudioService *-- Playlist
Playlist --> "1..*" MusicId
AudioService ..> SoundId
AudioService ..> ResourceService : loads and gets audio
AudioService ..> AudioSettingsApplier : registers applyVolumes as
ServiceLocator o-- AudioSettingsApplier
SettingsPanel ..> UserSettings : Apply
UserSettings ..> ServiceLocator : looks up the applier
PauseMenuFactory ..> AudioService : pause and resume events
AudioService is all static, so nothing holds an instance of it. GdxGame and
PauseMenuFactory call into it directly, and the settings reach it through the applier it
registers.
sequenceDiagram
participant Old as Current screen
participant Game as GdxGame
participant Audio as AudioService
participant SL as ServiceLocator
participant List as Playlist
participant RS as ResourceService
Old->>Game: setScreen(MAP)
Game-xOld: dispose()
Game->>Game: build the new MapScreen
Game->>Audio: onScreenChanged(MAP, runState)
opt no settings applier registered
Audio->>SL: registerAudioSettingsApplier(applyVolumes)
SL->>Audio: applyVolumes(saved music, saved effects)
end
opt the pause menu was open
Audio->>Audio: stop the pause track and the held song
end
alt the map playlist is already playing
Note over Audio: nothing changes
else anything else
Audio->>Audio: stopMusic()
Audio->>List: next()
List-->>Audio: a random map track, not the last one
Audio->>RS: load it if this screen hasn't
Audio->>RS: getAsset(track, Music)
RS-->>Audio: Music
Audio->>Audio: play on loop at the music volume
end
sequenceDiagram
actor Player
participant Menu as Pause menu
participant Game as GdxGame
participant Audio as AudioService
participant Song as Current song
participant Pause as Pause track
Player->>Menu: Escape or the pause button
Menu->>Audio: onGamePaused()
Audio->>Song: pause()
Audio->>Pause: play on loop
Note over Song: keeps its place
alt Resume
Player->>Menu: Resume
Menu->>Audio: onGameResumed()
Audio->>Pause: stop()
Audio->>Song: setVolume(music volume)
Audio->>Song: play()
Note over Song: carries on from the same spot
else Exit to Main Menu
Player->>Menu: Exit to Main Menu
Menu->>Game: setScreen(MAIN_MENU)
Game->>Audio: onScreenChanged(MAIN_MENU)
Audio->>Pause: stop()
Audio->>Song: stop()
Audio->>Audio: start the menu playlist
end
sequenceDiagram
actor Player
participant Panel as SettingsPanel
participant US as UserSettings
participant SL as ServiceLocator
participant Audio as AudioService
participant Song as Current song
Player->>Panel: moves the sliders and clicks Apply
Panel->>US: set(settings, true)
US->>US: save to settings.json
US->>SL: getAudioSettingsApplier()
SL-->>US: AudioService.applyVolumes
US->>Audio: applyVolumes(master × music, master × effects)
Note right of US: both are 0 while Mute is ticked
Audio->>Song: setVolume(music × 0.5)
Audio->>Audio: keep the effects volume for playSound
- Put the OGG file in
source/core/assets/music/. - Add its path to the end of
musicPathsinAudioService. - Add an id to the end of
MusicId. The ids match the paths by position, so both lists have to stay in the same order. - Add the id to a
Playlist, or add a new playlist and use it inonScreenChanged.
everyMusicAndSoundPointsAtARealFile in AudioServiceTest fails if an id and a path don't
line up, or if the file is missing.
| File | Plays on | Length | Feel |
|---|---|---|---|
music/menuHearthlight.ogg |
Main menu | 46 s | Warm and a bit wistful, plucked strings and a soft flute |
music/menuStorybook.ogg |
Main menu | 49 s | Music-box waltz over harp |
music/menuHeroesGate.ogg |
Main menu | 40 s | Brass march with timpani |
music/mapWanderer.ogg |
Map | 38 s | Easygoing travelling music, picked guitar |
music/mapFog.ogg |
Map | 51 s | Slow pads and bells, calm and a bit mysterious |
music/mapCrossroads.ogg |
Map | 44 s | Folk jig with lute and hand drum |
music/battleClash.ogg |
Battle | 41 s | Driving and rock-ish, square-wave lead |
music/battleIronclad.ogg |
Battle | 45 s | Heavier, taiko drums, strings and brass |
music/battleSkirmish.ogg |
Battle | 37 s | Fast chiptune |
music/eliteBattle.ogg |
Elite battles | 38 s | Faster and more aggressive than a normal battle |
music/bossBattle.ogg |
Final boss | 44 s | Choir, taiko drums and a big brass tune |
music/shop.ogg |
Shop | 37 s | Bouncy marimba, accordion and oom-pah bass |
music/libraryArchive.ogg |
Library screens | 51 s | Calm, slow harp and celesta |
music/librarySpecimens.ogg |
Library screens | 55 s | Dark pads and sparse bells |
music/libraryStudy.ogg |
Library screens | 46 s | Lo-fi electric piano and soft flute |
music/pauseMenu.ogg |
Pause menu | 58 s | Music box over soft strings and a slow harp |
sounds/victory.ogg |
Winning a battle | 4 s | Short fanfare |
sounds/defeat.ogg |
Losing a battle | 7 s | Slow falling flute line |
sounds/buttonClick.ogg |
Menu buttons | 0.1 s | Short wooden click |
AudioServiceTest has 33 tests and doesn't need a graphics context or a sound device.
ResourceService is mocked so the tests can see which tracks were started, the stage root is a
real Group that button events can be fired at, and the settings file points at a temp folder
so the tests never touch the real one.
What it covers:
- the right playlist for each screen, and the elite, boss and shop tracks from the room type
- no restart when moving between screens that share music (settings, events, the library screens)
- shuffling never repeating the last track
- victory and defeat stopping the music
- clicks on menu screens only, only from buttons, and on the save screen
- the saved volume at start, the sliders, mute, and the settings staying connected after a
screen clears the
ServiceLocator - pausing, resuming, pausing twice, resuming without pausing, quitting from the pause menu, and changing the volume while paused
- every
MusicIdandSoundIdpointing at a real file
To run just these:
cd source
./gradlew :core:test --tests "*AudioServiceTest"
What the tests can't check is how it sounds. By hand:
- go from the main menu into the settings and back, and the song should keep going
- start a run, enter a fight, a shop and an event, and check each one's music
- win and lose a fight
- press Escape on the map and in a fight, then resume, and the song should pick up where it stopped
- move each slider and press Apply, tick Mute, then restart the game to check the saved volume is used
- The volume only changes on Apply, not while a slider is being dragged.
- The victory sound stops the music, and the elite portal and temple screens don't have music of their own, so after an elite win they're quiet until you're back on the map.
- Events use the map music rather than their own.