Music - UQcsse3200/2026-studio-4 GitHub Wiki
Description
The game's music changes with what the player is doing rather than looping one track everywhere. A calm theme plays in the hub and in any room that has been cleared, a battle theme plays while a dungeon room still has enemies in it, and the final boss has a theme of its own. Clearing a room ends the fight music immediately, without waiting for the player to walk out.
Originally one placeholder track looped in every room including the hub. That track has since been removed from the project.
The tracks
| Track | File | Plays |
|---|---|---|
| Hub theme | sounds/lobby_music.mp3 |
The hub, and any dungeon room with nothing left alive in it |
| Battle theme | sounds/fight_music.mp3 |
A dungeon room that still has enemies |
| Boss theme | sounds/boss_music.mp3 |
The final dungeon, while the boss is alive |
The hub and battle themes were composed for this project. The boss theme is track 17 of xDeviruchi's 16-Bit Fantasy & Adventure Music, used under its licence. Provenance and the credit that licence requires are recorded in assets/licenses/AUDIO-ATTRIBUTION.md.
Implementation
Music is split into two parts: MusicManager, which owns the tracks and decides what plays, and RoomMusicComponent, which tells it when the player's situation changes.
In Sprint 3 all of this lived inside RoomAssets, next to texture and atlas loading, and was driven by a roomEntered event from RoomManager. When RoomManager was replaced by RoomSelector (#305) that event went away and room music stopped playing. Sprint 4 (#303) moved music into its own class and reconnected it to the new room events.
MusicManager
MusicManager (rooms/MusicManager.java) is created by MainGameScreen and registered in the ServiceLocator, so any room code can reach it with ServiceLocator.getMusicManager(). It has four public methods:
| Method | What it does |
|---|---|
load() |
Loads all three tracks into the ResourceService. Loading never starts a track. |
play(dungeonId, cleared) |
Picks and plays the right track for the room. |
applyVolume() |
Keeps every track on the current volume setting. Called every frame. |
dispose() |
Stops every track and unloads them. |
play picks a track like this:
String wanted = dungeonId == null || cleared
? CALM_MUSIC
: DUNGEON_MUSIC.getOrDefault(dungeonId, FIGHT_MUSIC);
Two details are deliberate. The hub is identified by having no dungeon, not by its room id, so renaming the hub in rooms.json cannot silently break the music. And a dungeon with a theme of its own is one entry in DUNGEON_MUSIC rather than a new branch, so giving another dungeon its own track is a one-line change.
Knowing which room the player is in
Every room entity gets a RoomMusicComponent, added in RoomFactory. It listens for two room events and calls MusicManager.play:
| Event | Fired when | Calls |
|---|---|---|
RoomSelector.ROOM_SWITCHED_EVENT |
The player arrives in a room | play(dungeonId, isRoomCleared), asking RoomClearService whether this room was cleared on an earlier visit |
RoomClearService.ROOM_CLEARED |
The last enemy in the room dies | play(dungeonId, true) |
The hub has no enemies, so it fires ROOM_CLEARED on its first frame and plays the calm theme.
Why cleared is passed in rather than looked up. The ticket suggested play(dungeonId), with the music asking RoomClearService itself. That gives the wrong answer at the moment a room is cleared: room components register their listeners before RoomSelector attaches RoomClearService's, so RoomMusicComponent hears ROOM_CLEARED first, before the clear has been recorded. Passing cleared directly avoids depending on that order. On room entry the lookup is safe, because any earlier clear was recorded on a previous visit.
If no MusicManager or RoomClearService is registered, for example in tests, the component does nothing instead of crashing.
Not restarting what is already playing
play returns early when the track it wants is already playing. Without this, every door between two contested rooms of the same dungeon would restart the battle theme from its opening bar. Switching tracks stops only the tracks being replaced, never the incoming one.
Volume
Volume is not fixed when a track starts. applyVolume() is called every frame from MainGameScreen.render() and sets every loaded track to AudioLevels.music(), which is the settings slider after the mute-when-unfocused rule. Moving the slider or clicking away from the window therefore takes effect immediately, on whichever track is playing.
Looping
Tracks are set to loop with libGDX's Music.setLooping(true), which restarts a track from its beginning. libGDX has no concept of a loop point, so a track with an intro replays that intro on every repeat. This only matters for the boss theme, whose first 13.8 seconds are an intro outside its intended loop, and only in a fight long enough to reach the end of a 4:32 track. It is documented in AUDIO-ATTRIBUTION.md rather than worked around.
Unit Tests
MusicManagerTest (9 tests) covers loading without starting a track, the right track for the hub, a contested dungeon room and the final dungeon, clearing a room returning to calm, the hub staying calm whatever its cleared state claims, never restarting a playing track, a missing track doing nothing, every track following the saved volume, and disposal stopping and unloading everything.
RoomMusicComponentTest (5 tests) covers entering an uncleared room, re-entering a cleared room, clearing a room, the hub passing no dungeon, and not crashing when no MusicManager is registered.
RoomMusicComponent is at 100% line and branch coverage. MusicManager is at 100% line and 87.5% branch coverage. See the Music Test Plan for the full tables and the manual checks.
How to Test
Run the game. The hub theme should begin as the player appears.
Hub. Talk to an NPC and open a menu — the hub theme keeps playing through the dialogue and the frozen world. Enter a dungeon and come back, and the hub theme returns.
Dungeons. Walking into a room with enemies starts the battle theme. Open the debug terminal (F1) and type room clear to empty the room without fighting: the battle theme stops and the calm theme returns immediately. Walk between two contested rooms of one dungeon and the battle theme continues rather than restarting. Re-enter a room already cleared and it stays calm.
Boss. Entering the final boss room plays the boss theme instead of the ordinary battle theme, and it holds across all three of the boss's stages because the boss remains one entity throughout. Beating it returns to the calm theme.
Volume. Open settings mid-run and move the music slider; the volume changes immediately on whichever track is playing. Enable "mute when unfocused" and click away from the window; the music silences and returns on focus.
Quitting. Return to the main menu mid-run; the music stops.
AI Declaration
This page was written with LLM assistance (Claude Code on Opus 5 and Opus 5.5), generated from the music implementation and its test suite, and reviewed before publishing.