Background Music in a Flame Game
The Flame version of Langaw has been released to the Google Play Store. The number one negative feedback I got was that the background music keeps on playing even after the game is closed.
The game has been updated and the background music issue has been fixed but the fix was a custom and non-reusable.
In this article, I’d like to share a class that you can just paste in your Flame game project and use immediately to manage and play looping background music files.
If you want to skip the boring part and just want to add background music to your games, skip to the “how to use“ section below.
Let’s Get Started
We would need a class that will manage the background music. All its properties and methods will be static
so we don’t need to create an instance of the class to use it.
Create a class in ./lib/bgm.dart
and add the following code:
import 'package:audioplayers/audio_cache.dart';
class BGM {
static List<AudioCache> _tracks = List<AudioCache>();
static int _currentTrack = -1;
static bool _isPlaying = false;
}
Note: The location ./
refers to the root directory of the project. It should be where the file pubspec.yaml
should be.
Breakdown: Import statement at the top just gives us access to the AudioCache
class from the audioplayers
library. What follows is a standard class declaration. The class is named BGM
and has three static properties. Static properties (and methods) are like global properties but only for that class. It’s like changing a property of the class itself instead of changing a property of an instance of the class.
All these properties are private though, properties or methods that start with an underscore (_
) are private and can only be changed/accessed from within the BGM
class itself.
Before all other functionalities, let’s add an update method first. This method will contain the code for managing the state (playing or pausing) the track.
static Future _update() async {
if (_currentTrack == -1) {
return;
}
if (_isPlaying) {
await _tracks[_currentTrack].fixedPlayer.resume();
} else {
await _tracks[_currentTrack].fixedPlayer.pause();
}
}
Breakdown: This is method is rather simple. If _currentTrack
is set to -1
, it means no track is playing. In this case, the method just return
s and ends.
If _currentTrack
is set to something else, the method checks for the value of _isPlaying
. If it’s true, the method then calls resume
on the track’s fixedPlayer
object. Otherwise, the method calls pause
.
Resource management
In a perfect world, we could just load all our audio assets into memory ready to be played at a moment’s notice. But electronic devices have limited memory, not to mention they probably don’t run just a single app or game.
Knowing this, it’s our responsibility as game developers to limit the resources we use to a minimum loading only the things we’re going to need inside a specific view.
Back to the code, we will need access to the AudioPlayer
class and the ReleaseMode
enum
that are defined in the audioplayers.dart
file of the audioplayers
library. Let’s import that file with the following line:
import 'package:audioplayers/audioplayers.dart';
Then let’s add a method called add
to the class. This method will also be static. I/O operations are involved so we’re also making this method asynchronous.
static Future<void> add(String filename) async {
AudioCache newTrack = AudioCache(prefix: 'audio/', fixedPlayer: AudioPlayer());
await newTrack.load(filename);
await newTrack.fixedPlayer.setReleaseMode(ReleaseMode.LOOP);
_tracks.add(newTrack)
}
Not much of a breakdown: This method involves preloading a background music track from a file. The specifics of each line gets a bit more in-depth with the audioplayers
package. For now, just know that what’s happening is we’re creating a new instance of the AudioCache
class and adding it to the _tracks
list.
The method above could be called at the beginning (entry point) of the game to preload resources or on loading screens based on how complex the game you’re developing is.
Now that audio resources can be added to the cache, we should have a way to remove those resources from the memory.
static void remove(int trackIndex) async {
if (trackIndex >= _tracks.length) {
return;
}
if (_isPlaying) {
if (_currentTrack == trackIndex) {
await stop();
}
if (_currentTrack > trackIndex) {
_currentTrack -= 1;
}
}
_tracks.removeAt(trackIndex);
}
Note: We’re calling on the stop
method here which doesn’t exist yet. We’ll write it into the class later in the next section.
Breakdown: The method above accepts an int
(trackIndex
) as a parameter and checks if that index exists. If trackIndex
is greater or equal to the number of items in _tracks
list, the method just return
s and ends.
Next, it checks if a BGM is currently playing. If _isPlaying
is true
and the currently playing BGM track is the one being removed, call stop
first. If the currently playing BGM track is lower in the list than the one being removed, we need to update the _currentTrack
variable so its value will point correctly to the new index after removing an item above it.
Finally, we remove the track from the _tracks
list.
There are scenarios where you want to just unload all BGM tracks and start over. This is useful if you’re changing scenes with a totally different mood or when going back to the main menu.
Let’s write a method to do just that.
static void removeAll() {
if (_isPlaying) {
stop();
}
_tracks.clear();
}
Breakdown: First, we stop
the currently playing BGM track if there’s any. After that, we clear the _tracks
list. That’s it.
Play, stop, pause, and resume
To control the BGM tracks, we write the following methods.
The first method is play
. This method accepts an int
named trackIndex
as a parameter that specifies which track should be played.
static Future play(int trackIndex) async {
if (_currentTrack == trackIndex) {
if (_isPlaying) {
return;
}
_isPlaying = true;
_update();
return;
}
if (_isPlaying) {
await stop();
}
_currentTrack = trackIndex;
_isPlaying = true;
AudioCache t = _tracks[_currentTrack];
await t.loop(t.loadedFiles.keys.first);
_update();
}
Breakdown: The first thing that the play
method does is check if the supplied trackIndex
is the same as the currently playing track index. If it’s a match and it’s currently playing, the method immediately ends through a return
. If it matches but isn’t playing (I can’t really imagine how this scenario could happen, but let’s just put here as a safety net), set the variable _isPlaying
to true
and call _update()
then end through a return
.
If the passed trackIndex
value is not the same as the current one, it means that the playing track is being changed (even if there isn’t one playing currently). A check is done if there is something playing by checking the variable _isPlaying
‘s value. If something is playing, stop
is called to make sure the other track is stopped properly.
Note: Again, the stop
method does not exist yet, but we’ll get to shortly (right after this breakdown actually).
After all the preparation is done, we set the _currentTrack
value to whatever was passed on trackIndex
and set _isPlaying
to true
.
Here’s the most confusing bit, first we get the actual AudioCache
“track” and assign it to the variable named t
. We then call the loop
method of this track and pass in the filename of the first loaded file. To clear things up, when we call the add
method above, it adds a new track (AudioCache
object) to the list. Each track holds a cache of loaded music files in a Map
named loadedFiles
where the keys are stored as String
s. For each cached music file, the key is set to the filename you supply to it. In this class, one track only holds/caches one music file so we can get the original filename passed by accessing .keys.first
of the loadedFiles
property.
To know more about Dart Map
s, check out the Map
documentation.
Now let’s go to the stop
method.
static Future stop() async {
await _tracks[_currentTrack].fixedPlayer.stop();
_currentTrack = -1;
_isPlaying = false;
}
Breakdown: The method simply calls stop
on the track’s fixedPlayer
property. This method is more on the audioplayers
package side so check out the docs if you want to learn more about it.
Next, we have pause
.
static void pause() {
_isPlaying = false;
_update();
}
And lastly, resume
.
static void resume() {
_isPlaying = true;
_update();
}
Breakdown: The pause
and resume
methods just set the value of _isPlaying
to false
and true
, respectively. After that, _update
is called and it handles pausing and resuming there.
Solving the background music problem
We now have a class that can manage resources (caching and preloading) and control tracks (play, stop, pause, resume).
Yay! Yeah… no.
The original problem was that the music kept on playing even after exiting the game or switching from the game to another app.
How do we solve this?
We extend a WidgetsBindingObserver
and listen to app lifecycle state changes.
Let’s create another class.
class _BGMWidgetsBindingObserver extends WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
BGM.resume();
} else {
BGM.pause();
}
}
}
Breakdown: This class extends WidgetsBindingObserver
and overrides the method didChangeAppLifecycleState
. This method is fired every time the lifecycle state of the app is changed. The new state is passed as a parameter. If the app just resumed, BGM.resume()
is called. For all other states, BGM.pause()
is called.
This class by itself does nothing, we need to create an instance of it first and attach it as an observer to the WidgetsBinding
instance. Besides, this class is private (its name starts with an underscore) so it’s no use outside this file.
The BGM
class, on the other hand, is a static global singleton, supposedly anyway. So back to the BGM
class, let’s add a private property and a getter for that property.
static _BGMWidgetsBindingObserver _bgmwbo;
static _BGMWidgetsBindingObserver get widgetsBindingObserver {
if (_bgmwbo == null) {
_bgmwbo = _BGMWidgetsBindingObserver();
}
return _bgmwbo;
}
Breakdown: We’re adding two class members here, a private property (_bgmwbo
) and a getter (widgetsBindingObserver
) which returns the private property. We use this private property–getter combo so we can store instantiate a value if it’s null and store it for referencing later. In the end, if we access widgetsBindingObserver
, we’re actually getting the _bgmwbo
property. This setup just gives the class a chance to initialize it first.
Next, let’s add a helper function that will bind our observer into the WidgetsBinding
instance.
static void attachWidgetBindingListener() {
WidgetsBinding.instance.addObserver(BGM.widgetsBindingObserver);
}
Breakdown: It’s a pretty standard one-liner. We get the WidgetsBinding
instance and call its addObserver
method passing BGM.widgetsBindingObserver
(which is a getter that initializes an instance of _BGMWidgetsBindingObserver
if there’s none).
Now we have a working, reusable class file that deals with looping BGM tracks.
How to use in your Flame game
First thing’s first, you must have this file in an easily accessible location. Having this file in ./lib/bgm.dart
works best. But it’s really up to you. Having it in ./lib/globals/bgm.dart
, for example, works too.
Here’s the whole class file:
import 'package:audioplayers/audio_cache.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/widgets.dart';
class BGM {
static List _tracks = List();
static int _currentTrack = -1;
static bool _isPlaying = false;
static _BGMWidgetsBindingObserver _bgmwbo;
static _BGMWidgetsBindingObserver get widgetsBindingObserver {
if (_bgmwbo == null) {
_bgmwbo = _BGMWidgetsBindingObserver();
}
return _bgmwbo;
}
static Future _update() async {
if (_currentTrack == -1) {
return;
}
if (_isPlaying) {
await _tracks[_currentTrack].fixedPlayer.resume();
} else {
await _tracks[_currentTrack].fixedPlayer.pause();
}
}
static Future add(String filename) async {
AudioCache newTrack = AudioCache(prefix: 'audio/', fixedPlayer: AudioPlayer());
await newTrack.load(filename);
await newTrack.fixedPlayer.setReleaseMode(ReleaseMode.LOOP);
_tracks.add(newTrack);
}
static void remove(int trackIndex) async {
if (trackIndex >= _tracks.length) {
return;
}
if (_isPlaying) {
if (_currentTrack == trackIndex) {
await stop();
}
if (_currentTrack > trackIndex) {
_currentTrack -= 1;
}
}
_tracks.removeAt(trackIndex);
}
static void removeAll() {
if (_isPlaying) {
stop();
}
_tracks.clear();
}
static Future play(int trackIndex) async {
if (_currentTrack == trackIndex) {
if (_isPlaying) {
return;
}
_isPlaying = true;
_update();
return;
}
if (_isPlaying) {
await stop();
}
_currentTrack = trackIndex;
_isPlaying = true;
AudioCache t = _tracks[_currentTrack];
await t.loop(t.loadedFiles.keys.first);
_update();
}
static Future stop() async {
await _tracks[_currentTrack].fixedPlayer.stop();
_currentTrack = -1;
_isPlaying = false;
}
static void pause() {
_isPlaying = false;
_update();
}
static void resume() {
_isPlaying = true;
_update();
}
static void attachWidgetBindingListener() {
WidgetsBinding.instance.addObserver(BGM.widgetsBindingObserver);
}
}
class _BGMWidgetsBindingObserver extends WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
BGM.resume();
} else {
BGM.pause();
}
}
}
Setup
In your game’s ./lib/main.dart
, import the BGM class file:
import 'bgm.dart';
Or if it’s under globals:
import 'globals/bgm.dart';
Note: You could use the more common import format which specifies your package name and a full path to the file. Again, it’s up to you.
Then have the following line AFTER calling runApp
:
BGM.attachWidgetBindingListener();
Preloading (and unloading)
Preloading/adding tracks:
await BGM.add('bgm/awesome-intro.ogg');
await BGM.add('bgm/flame-game-level.ogg');
await BGM.add('bgm/boss-fight.ogg');
Unloading all tracks (freeing the memory from BGM tracks):
BGM.removeAll();
Rarely, you may want to unload a single track (let’s say you won’t need the boss fight track anymore):
BGM.remove(2);
Playing (or changing tracks)
To play a track, just call:
// intro or home screen
BGM.play(0);
// boss fight is about to start
BGM.play(2);
It’s okay if a different track is currently playing. The class will handle it by stopping the currently playing one and playing the one specified (as you can see in the code above).
Stopping
To stop the current track, just call:
await BGM.stop();
Note: Some of the function calls above need to be added inside an async
function if you care about timing (you should).
Conclusion
That was it. Have fun making games with awesome background music tracks that automatically stop when you exit the game or switch to a different app.
If you have any questions, contact me with an email, drop a comment below, or join my Discord server.