Music‐and‐Screen‐Audio - UQcsse3200/2026-studio-3 GitHub Wiki

Music and Screen Audio

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

Overview

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.

What plays where

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.

Playlists

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.

Elite, boss and shop music

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_SCREEN plays the boss track in a FINAL room, the elite track in an ELITE room, and the battle playlist anywhere else.
  • ENCOUNTER switches to the shop track in a SHOP room. 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.

Victory and defeat

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.

Button clicks

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.

Screens with their own ResourceService

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.

Volume settings

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.
  • playSound multiplies 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.

Pause menu music

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.

Diagrams

Classes

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
Loading

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.

Changing screens

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
Loading

Pausing

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
Loading

Changing the volume

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
Loading

Adding a track

  1. Put the OGG file in source/core/assets/music/.
  2. Add its path to the end of musicPaths in AudioService.
  3. 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.
  4. Add the id to a Playlist, or add a new playlist and use it in onScreenChanged.

everyMusicAndSoundPointsAtARealFile in AudioServiceTest fails if an id and a path don't line up, or if the file is missing.

Audio files

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

Testing

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 MusicId and SoundId pointing 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

Known limitations

  • 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.
⚠️ **GitHub.com Fallback** ⚠️