Scoring, Storage, and Sound Tutorial – Step by Step with Flame and Flutter (Part 4 of 5)
Scoring and keeping the highest scores are essential for any game. Some games base the score on the number of coins collected, some base it on enemies killed, and some base it on survival time.
We can’t ignore sound effects and background music either. Combined, those two could make or break a game.
Besides a great game isn’t really a great game if it’s too quiet.
Welcome to the fourth part of the game development tutorial series with Flame and Flutter. We’re picking up where we left off and if you haven’t read the previous parts, I recommend you start with them.
Note: Playing background music in this tutorial is a bit outdated and problematic. An updated guide is available here. You can still follow the tutorial and head on over to apply the background music changes immediately after this part.
Here’s the whole series:
- Introduction to game development
- Set up a playable game
- Graphics and animation
- Views and dialog boxes
- Scoring, storage, and sound (you are here)
- Finishing up and packaging
Prerequisites
- All the requirements from the previous part.
- More graphics assets – Graphics assets can be found all over game resource sites on the internet (Open Game Art for example). Just make sure to credit the makers.
- Sound and music assets – Similar to graphics assets audio files can also be found on game resource sites like Open Game Art. There are even resource sites dedicated to audio resources like BenSound.com. Again, same with graphics, just make sure you understand the license and credit the creators if necessary.
We will be using the conventions from the previous parts regarding code and file path references.
All the code for this tutorial is available for viewing and download on this GitHub repository.
Another Resource Pack
In this part, we have another resource pack. This pack contains additional graphics, background music, and some sound effects.
Click the image above or this link to download!
Important Note: The resource pack above can be used if you’re following along with this tutorial. It is part of the Langaw project on GitHub which is licensed with a CC-BY-NC-ND
license.
It means that you can share, copy, and redistribute the material in any medium or format with some restrictions.
- You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- You may not use the material for commercial purposes.
- If you remix, transform, or build upon the material, you may not distribute the modified material.
- You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
Learn more about the CC-BY-NC-ND license by clicking here.
Let’s continue the build
In this part, we’ll be focusing on two main things: scoring and sound effects.
We will be using another Flutter plugin for storing the high scores. We need to store the highest score that the player achieves.
As for the sound, we’ll use an audio library that is already included with Flame.
Step 1: Scoring
The game’s goal, for now, is just to tap flies endlessly until the player misses. Aside from that, there’s no goal, no reward.
Let’s add in another goal. Let’s make it so that when a player taps and hits a fly, one point will be rewarded and added to the current score. Like any other game, the score starts at zero and resets to zero whenever a game is started.
A variable to keep the score
Let’s jump into the code and open up ./lib/langaw-game.dart
. Add in another instance variable using the following code:
int score;
Just a quick refresher, instance variables are grouped together just below the class LangawGame
line.
Inside the initialize
method, let’s initialize the value of the score
variable as soon as possible with the following line:
score = 0;
We need to reset this value every time the start button is clicked (every time a game is started).
Jump over to ./lib/components/start-button.dart
and add the following line inside the onTapDown
handler:
game.score = 0;
Displaying the score
So far so good! Next, we need to display the score.
The rendering code can be inside the game class. But, since rendering text on the canvas is quite a handful, let’s encapsulate that logic in its own component.
Create a component in ./lib/components/score-display.dart
:
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class ScoreDisplay {
final LangawGame game;
ScoreDisplay(this.game) {}
void render(Canvas c) {}
void update(double t) {}
}
Let’s break it down: Starting with the import
statements at the top, dart:ui
gives us access to the Canvas
and Offset
classes; package:flutter/painting.dart
gives us access to the TextPainter
class; and package:langaw/langaw-game.dart
gives us access to the game class.
Note: We already have an instance variable named game
and this must be provided when creating an instance of this class. The same as all other components, controllers, and views we’ve defined in the previous parts.
Let’s add three more instance variables: painter
is the TextPainter
that we’ll use to render the value of the score on the screen; textStyle
contains the styles that will control how the score is rendered; position
will contain the Offset
where the score will be painted on.
TextPainter painter;
TextStyle textStyle;
Offset position;
Then we have the constructor where we initialize the values of the instance variables.
ScoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 90,
shadows: <Shadow>[
Shadow(
blurRadius: 7,
color: Color(0xff000000),
offset: Offset(3, 3),
),
],
);
position = Offset.zero;
}
Breakdown: First, we initialize the painter
variable with a new instance of TextPainter
. We set its textAlign
property to center since we’ll be rendering the score horizontally centered on the screen. The textDirection
property is set to LTR (left-to-right) since the game is in English (which has a written direction of left to right).
Next, the textStyle
property is initialized with an instance of TextStyle
. Three properties are set: color
is set to Color(0xffffffff)
(which is solid white); font size is set to 90 logical pixels
; and the shadows
property is set to a List
of Shadow
s that contains only one item. This item defines a shadow that is offset to 3 logical pixels
to the right and to the bottom. This gives the rendered text depth and contrast against the background in case the score is rendered on top of another white object (like the clouds).
To learn more about logical pixels please check the Flutter documentation about it.
The update
method actually happens before the render (both initially and during the game loop) so let’s write it in first.
void update(double t) {
if ((painter.text?.text ?? '') != game.score.toString()) {
painter.text = TextSpan(
text: game.score.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
(game.screenSize.width / 2) - (painter.width / 2),
(game.screenSize.height * .25) - (painter.height / 2),
);
}
}
Breakdown: To avoid unnecessary recalculation of the text layout, nothing really happens if the text property (of the text property) of the painter
variable is equivalent to the string form of the current score.
This is done with the following check:
(painter.text?.text ?? '') != game.score.toString()
The left part of the boolean expression might be a little confusing, so let me explain. This expression uses Dart’s null-aware operators.
The ?.
operator checks if the object just before it is null
, if it is, immediately stop the whole expression and return null
. We already know that painter
is initialized and not null so we don’t check it. We’re not sure though if the text
property of painter
is null
or not, so we use this operator.
Another operator used is ??
. This operator returns the left-hand side expression if it’s not null
, if it is, the operator returns the right-hand side expression.
With the whole expression, if painter
‘s text
property is not set, the whole of painter.text?.text
returns null
. Since it’s followed by ??
, if the value returned is an empty string. This final value is what gets compared to game.score.toString()
. On the other hand, if painter
‘s text
property is set, the actual current score is returned.
You can read more about null-aware operators from this article. If you have more questions, don’t hesitate to ask me on my Discord server.
Now if the painter
‘s text is not the same as the current score, we update its text property with a new instance of the TextSpan
class that takes in the current value of the score
variable in game
and the reusable textStyle
variable. Then the layout
method is called so the TextPainter
can calculate the dimensions of the new text it was just assigned.
Then we calculate a new instance of Offset
and assign it to the position
variable. We want the score to be horizontally centered. As for the vertical position, let’s place the score’s vertical center in about a quarter of the screen’s height from the top.
If you need an explanation about this calculation, just let me know.
Finally, inside the render
method, add the following line of code:
void render(Canvas c) {
painter.paint(c, position);
}
One line breakdown: We render the score by calling painter
‘s paint
method and supplying the parameters it requires: a canvas to draw on and an Offset
to tell the painter where to draw the score.
The whole ./lib/components/score-display.dart
should look like this:
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class ScoreDisplay {
final LangawGame game;
TextPainter painter;
TextStyle textStyle;
Offset position;
ScoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 90,
shadows: <Shadow>[
Shadow(
blurRadius: 7,
color: Color(0xff000000),
offset: Offset(3, 3),
),
],
);
position = Offset.zero;
}
void render(Canvas c) {
painter.paint(c, position);
}
void update(double t) {
if ((painter.text?.text ?? '') != game.score.toString()) {
painter.text = TextSpan(
text: game.score.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
(game.screenSize.width / 2) - (painter.width / 2),
(game.screenSize.height * .25) - (painter.height / 2),
);
}
}
}
Rendering the score component
To actually render the score display component, we must add it to the game class and include it when executing the game loop.
Let’s open up ./lib/langaw-game.dart
and do the usual steps. Import the class file we just created:
import 'package:langaw/components/score-display.dart';
Then let’s add an instance variable that will hold an instance of ScoreDisplay
:
ScoreDisplay scoreDisplay;
Add the following inside the initialize
method (after resize
has been called) to create a new instance of ScoreDisplay
and assign it to the scoreDisplay
variable (mind the capitalization), preferably below the button initializations:
scoreDisplay = ScoreDisplay(this);
Inside the update
method, check if the current view is set to View.playing
, and if it is, call scoreDisplay
‘s update
method. You can put this line anywhere.
Putting it as the last line (just above the method closing brace) is fine.
if (activeView == View.playing) scoreDisplay.update(t);
Then inside the game class’ render
method, we do the same thing but call the render
method instead. Remember that the order that you write the lines inside this method is the actual order the graphics are drawn on the screen with.
We want the score to be just above the background but behind everything else so flies can fly over it. So put the following line just after rendering the background:
if (activeView == View.playing) scoreDisplay.render(canvas);
With the above lines of code added, you should see something like this when you run the game:
Score for every fly
For players to earn score points, flies must be tapped and dropped.
To have this functionality, open up ./lib/components/fly.dart
. Let’s import the View
enum
file as we will be needing access to the View
values below.
import 'package:langaw/view.dart';
Inside the onTapDown
handler, replace the existing single line of code with the following block:
if (!isDead) {
isDead = true;
if (game.activeView == View.playing) {
game.score += 1;
}
}
Breakdown: When a fly is tapped, we first check if it’s alive (!isDead
). Nothing happens if the fly is already dead. If it’s alive, we set its isDead
property to true
to let the component know that this fly is now dead (just like what we previously had in here).
After that, we check if the current view is the “playing” view. We don’t need to add scores if the player is not playing. If the player is currently playing, we add 1
to the score variable. This should trigger an update in the ScoreDisplay
instance.
Try running the game at this point to see something like the screenshot below.
Smarter flies
Flies don’t just fly around, they eat too. Besides, it would be boring if the player just waits and carefully taps the flies to not miss.
Let’s add another losing condition.
As soon as a fly appears on screen, it shows a countdown timer. This counter corresponds to how much the flies have eaten. When the timer hits zero, it means that the fly is full and the player loses.
This way, the game becomes more challenging and a little less boring.
We have to make sure that the player notices this countdown timer and see which flies are about to get full. We’ll use the callout graphics from the resource pack to show this countdown timer.
Let’s add the callout graphics from the resource pack into our assets directory under ./assets/images/ui/callout.png
. Then we make sure to add it in ./pubspec.yaml
using the following line along with the other assets:
- assets/images/ui/callout.png
Then open up ./lib/main.dart
and add ui/callout.png
to the list of preloaded images. This is inside the array that is a parameter to the Flame.images.loadAll
call.
Flame.images.loadAll([
// all the other images
'ui/callout.png',
]);
Next, create a component for this callout in ./lib/components/callout.dart
.
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:langaw/components/fly.dart';
class Callout {
final Fly fly;
Rect rect;
Sprite sprite;
double value;
TextPainter tp;
TextStyle textStyle;
Offset textOffset;
Callout(this.fly) {}
void render(Canvas c) {}
void update(double t) {}
}
Breakdown: Here we create yet another component that is pretty standard. Except that our reference to the parent is not to game
but to a fly. This is because a Fly
class will instantiate this instead of the game class. Like a sub-component of the fly component.
This class has additional instance variables that will be used for painting the value inside the callout.
Let’s proceed by initializing the values inside the constructor:
Callout(this.fly) {
sprite = Sprite('ui/callout.png');
value = 1;
tp = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xff000000),
fontSize: 15,
);
}
Breakdown: Don’t be confused, the body is actually just four lines. The last two are expanded vertically for readability. Inside the constructor, we just assign initial values to our instance variables.
Inside the update
method, we subtract a certain amount to the value
variable and if gets down to zero, we set the game’s active view to the “you lose” view. All this should only happen if the game is currently in to the “playing” view. Put the following block into the update
method.
if (fly.game.activeView == View.playing) {
value = value - .5 * t;
if (value <= 0) {
fly.game.activeView = View.lost;
}
}
Breakdown: First we check if the current view is View.playing
, if it is we subtract .5 * t
from value. The t
variable contains a fraction of the second from the last time update
was called. This calculation makes sure that a fly has a lifetime of two seconds.
Then we check if the value has hit zero. If it has, we tell the game that the player loses.
After that, we make sure that the rect
variable for this callout is updated so that the render
method places it correctly relative to the parent fly. This block of code goes just below the value
deduction block.
rect = Rect.fromLTWH(
fly.flyRect.left - (fly.game.tileSize * .25),
fly.flyRect.top - (fly.game.tileSize * .5),
fly.game.tileSize * .75,
fly.game.tileSize * .75,
);
Breakdown: Just like all other Rect
initializations we’ve done so far, this is just one line spread vertically. The last two parameters are the Width and Height of the rect
which are both set to three-quarters of the game tile size. The value for Left is just the same as the Left of the fly’s rect
minus a quarter of the game tile size. The Top value uses the same logic but subtracts half of the game tile size.
Still inside the update
method, the last block of code updates the text painter which draws the current value inside the callout graphics.
tp.text = TextSpan(
text: (value * 10).toInt().toString(),
style: textStyle,
);
tp.layout();
textOffset = Offset(
rect.center.dx - (tp.width / 2),
rect.top + (rect.height * .4) - (tp.height / 2),
);
Breakdown: Since we already initialized the tp
variable with an instance of the TextPainter
class, we just set its text property into an instance of the TextSpan
class passing in the current value
multiplied by ten converted to an integer and then to a string.
The value is multiplied by ten to make it look like it’s counting from nine down to zero.
Then we call the layout
method so that the tp
knows how big the text is going to be given the string of text and the style provided to it.
Next, we update the value of textOffset
with a new Offset
passing in a calculation that will center the text inside the white area of the callout.
Finally, write the render
method to look like the following block of code:
void render(Canvas c) {
sprite.renderRect(c, rect);
tp.paint(c, textOffset);
}
First, we render the callout graphics, which should be a familiar line of code now. Then we draw the text using TextPainter
‘s paint
method passing the textOffset
variable that we just updated in the update
method.
The whole callout class file should look like this:
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:flutter/painting.dart';
import 'package:langaw/components/fly.dart';
import 'package:langaw/view.dart';
class Callout {
final Fly fly;
Rect rect;
Sprite sprite;
double value;
TextPainter tp;
TextStyle textStyle;
Offset textOffset;
Callout(this.fly) {
sprite = Sprite('ui/callout.png');
value = 1;
tp = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textStyle = TextStyle(
color: Color(0xff000000),
fontSize: 15,
);
}
void render(Canvas c) {
sprite.renderRect(c, rect);
tp.paint(c, textOffset);
}
void update(double t) {
if (fly.game.activeView == View.playing) {
value = value - .5 * t;
if (value <= 0) {
fly.game.activeView = View.lost;
}
}
rect = Rect.fromLTWH(
fly.flyRect.left - (fly.game.tileSize * .25),
fly.flyRect.top - (fly.game.tileSize * .5),
fly.game.tileSize * .75,
fly.game.tileSize * .75,
);
tp.text = TextSpan(
text: (value * 10).toInt().toString(),
style: textStyle,
);
tp.layout();
textOffset = Offset(
rect.center.dx - (tp.width / 2),
rect.top + (rect.height * .4) - (tp.height / 2),
);
}
}
Now we just need to add an instance of the callout component into the Fly
class. This process should be familiar as we have done it repetitively in the previous parts. Open up ./lib/components/fly.dart
and import the Callout
class file:
import 'package:langaw/components/callout.dart';
Then add an instance variable that will hold the callout:
Callout callout;
Inside the Fly
constructor, initialize the callout
variable and pass this
as the callout’s reference to “this fly”:
callout = Callout(this);
The callout itself must be updated if the fly is not dead, so inside the Fly
‘s update
method write the following line at the bottom of the else
(the fly is not dead) block.
callout.update(t);
Lastly, render the callout if the fly is not dead (inside the else
block) and the game’s current view is set to “playing”. Put this inside the render
method:
if (game.activeView == View.playing) {
callout.render(c);
}
If you run the game now, you should see something like this:
View the code at this step on GitHub.
Step 2: High score storage
Gaining those precious points to add to the score would be a waste if the highest score is not recorded by the game.
Let’s record the highest score so players have the opportunity to get better at the game by challenging the current best performance.
When recording the highest score, we only need to store one piece of information. Just the score, an integer.
This simple task can be accomplished by the shared_preferences
plugin. This plugin contains the SharedPreferences
class that handles storage of simple data (numbers, strings, and booleans). It also internally handles how to save the data based on what platform (iOS or Android) the game is running.
Prepare the data storage
Just like Flame, shared_preferences
is a plugin for Flutter. To install this plugin, open up ./pubspec.yaml
and add the following line under the dependencies:
section just below the flame
line.
shared_preferences: ^0.5.1+2
Note: Mind the indentation, ./pubspec.yaml
is dependent on the indentation to know which lines are under what section.
Next, run flutter packages get
in a terminal to make sure that the shared_preferences
plugin is downloaded to your computer like so:
$ flutter packages get
Alternatively, if you’re following by using VS Code, you can press Ctrl + Shift + P
, type “flutter” and click Flutter: Get Packages
.
To easily read and write data, we need an easily accessible instance of SharedPreferences
in an instance variable in the game
context.
Open up ./lib/langaw-game.dart
and import the shared_preferences
package file:
import 'package:shared_preferences/shared_preferences.dart';
Then add a final
instance variable inside the LangawGame
class. This makes having an instance of the SharedPreferences
ready before even creating the LangawGame
instance. Declare the instance variable like so:
final SharedPreferences storage;
Any instance variable that is marked final
must have an initial value by the time it is declared or a value must be fed to it via the constructor. So let’s modify our constructor method so that it looks like the following block of code:
LangawGame(this.storage) {
initialize();
}
In the constructor, when you require a parameter that is prefixed by a this.
it means that whatever value passed to it will be the value of the variable that it’s referring to by name.
After that, let’s jump over the ./lib/main.dart
file so we can initialize the game class properly.
First, we need to import the shared_preferences
package file here too:
import 'package:shared_preferences/shared_preferences.dart';
Next, create an instance of the SharedPreferences
inside the main
function.
SharedPreferences storage = await SharedPreferences.getInstance();
Note: The .getInstance
factory returns a Future
so we must use the await
keyword to pause the execution and wait for whatever the Future
returns (which should be an instance of SharedPreferences
). The main
function is already async
so we can await for Future
s in its body.
In the part of the code where you declare an instance of the LangawGame
class, pass the storage
variable we just declared as the first (and only) parameter. The code should look like the following:
LangawGame game = LangawGame(storage);
Now we should have access to the storage variable wherever we have access to the game
(instance of the LangawGame
class).
Display the high score
Our game should feature an always-displayed high-score. We can do this using another component similar to the ScoreDisplay
class.
Create a class file in ./lib/components/highscore-display.dart
and write in the following class:]
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class HighscoreDisplay {
final LangawGame game;
TextPainter painter;
TextStyle textStyle;
Offset position;
HighscoreDisplay(this.game) {}
void render(Canvas c) {}
}
As you can see, it’s a pretty standard class file without an update
method since the score will be updated manually.
The instance variables must be initialized by the time an instance of this class is created so let’s modify the constructor with the following code:
HighscoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
Shadow shadow = Shadow(
blurRadius: 3,
color: Color(0xff000000),
offset: Offset.zero,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 30,
shadows: [shadow, shadow, shadow, shadow],
);
position = Offset.zero;
updateHighscore();
}
Constructor breakdown: The first part initializes the painter
variable with an instance of the TextPainter
class with the required values for text alignment and text direction.
Next, we build a local Shadow
variable that will help create a stroke effect when added to the textStyle
that is initialized next. We put in four instances of the shadow
variable so together when overlapped, they look like a text stroke.
The position
variable is set to an initial value of zero (0, 0
) as this will be calculated later.
Finally, we call a function named updateHighscore()
which we haven’t defined or written yet. This handles the manual update of the high-score value and the text painted by the painter
object.
Let’s build the manual update method by adding in the following function:
void updateHighscore() {
int highscore = game.storage.getInt('highscore') ?? 0;
painter.text = TextSpan(
text: 'High-score: ' + highscore.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
game.screenSize.width - (game.tileSize * .25) - painter.width,
game.tileSize * .25,
);
}
Breakdown: In this method, we get the high-score value from the SharedPreferences
instance we have saved in the storage
variable of the game class. Since our scores (including the high-score) are just whole numbers we’ll store them as integers.
Then we update the painter
‘s text
property to a new instance of TextSpan
passing in the high-score we just retrieved as the text. This is almost similar to the updating process in ScoreDisplay
.
After calling layout
(which determines the size of the text when painted), we set the position
variable to a new Offset
whose values will make the painted text’s right side at about one-fourth from the right edge of the screen and it’s top at the same distance from the top edge of the screen.
We finish the class by writing in the contents of the render
method:
void render(Canvas c) {
painter.paint(c, position);
}
Nothing complicated here, just a painting the high-score on the screen at the position
pre-calculated in the updateHighscore
method.
Here’s the whole class file:
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'package:langaw/langaw-game.dart';
class HighscoreDisplay {
final LangawGame game;
TextPainter painter;
TextStyle textStyle;
Offset position;
HighscoreDisplay(this.game) {
painter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
Shadow shadow = Shadow(
blurRadius: 3,
color: Color(0xff000000),
offset: Offset.zero,
);
textStyle = TextStyle(
color: Color(0xffffffff),
fontSize: 30,
shadows: [shadow, shadow, shadow, shadow],
);
position = Offset.zero;
updateHighscore();
}
void updateHighscore() {
int highscore = game.storage.getInt('highscore') ?? 0;
painter.text = TextSpan(
text: 'High-score: ' + highscore.toString(),
style: textStyle,
);
painter.layout();
position = Offset(
game.screenSize.width - (game.tileSize * .25) - painter.width,
game.tileSize * .25,
);
}
void render(Canvas c) {
painter.paint(c, position);
}
}
The only thing missing now is to add it to the game class. Let’s do that by opening the file ./lib/langaw-game.dart
.
Import the HighscoreDisplay
class with the following line:
import 'package:langaw/components/highscore-display.dart';
Then add an instance variable that will hold an instance of HighscoreDisplay
.
HighscoreDisplay highscoreDisplay;
Inside the initialize
method, initialize the highscoreDisplay
variable (preferably below scoreDisplay
):
highscoreDisplay = HighscoreDisplay(this);
Finally inside the render
method, right after rendering the background (before rendering scoreDisplay
), render the high score using the following code:
highscoreDisplay.render(canvas);
Try running the game and you should see some text on the upper-right corner of the screen indicating the current high-score (which is just zero for now).
Updating the high score
That high-score display is useless right now. It needs to be updated every time a player scores as long as the following conditions are met:
- The player is currently playing (the view is set to “playing”)
- The current score is higher than the current high-score
To do this, let’s open the file ./lib/components/fly.dart
. Inside the onTapDown
handler, we already have an if
block that checks if the player is currently playing.
Inside that block, just below the line where we add a value of 1
to the score, insert the following block:
if (game.score > (game.storage.getInt('highscore') ?? 0)) {
game.storage.setInt('highscore', game.score);
game.highscoreDisplay.updateHighscore();
}
Breakdown: This block just checks if the new score is higher than what is currently saved as high-score. At this point, we already know that the player is playing because of the enclosing if
block.
If the condition is satisfied, first we call the setInt
method passing over the string 'highscore'
and the new value. This method is the equivalent of getInt
but writes the passed value in the string key reference instead of reading a value.
After that, we update our HighscoreDisplay
instance manually to show the player that his new score is now the current highest.
The changed section should look like this:
Try running the game now and you’ll see that every time you get a higher score than what is displayed in the high-score (upper-right), that value will be updated.
It should start from zero and follow along as you score. If you play another game, you should see that the previous high-score is retained. This should challenge the player to beat his own record.
View the code at this step on GitHub.
Step 3: Sound effects
Note: Macs and iOS devices seem to have trouble playing .ogg
files. If you encounter a similar problem, please convert the .ogg
files to .mp3
files first using your favorite audio conversion tool. Please also note that for the rest of the tutorial, replace the .ogg
extensions with .mp3
.
What’s a game without sound effects right? Fortunately for us, Flame made adding sound to a game really easy.
First, we need to create a directory in the assets folder that will contain our sound effects files. Create the directory ./assets/audio/sfx
and paste all the files inside the ./audio/sfx
folderin the resource pack.
You should have the following file structure:
./assets
./assets/audio
./assets/audio/sfx
./assets/audio/sfx/haha1.ogg
./assets/audio/sfx/haha2.ogg
./assets/audio/sfx/haha3.ogg
./assets/audio/sfx/haha4.ogg
./assets/audio/sfx/haha5.ogg
./assets/audio/sfx/ouch1.ogg
./assets/audio/sfx/ouch2.ogg
./assets/audio/sfx/ouch3.ogg
./assets/audio/sfx/ouch4.ogg
./assets/audio/sfx/ouch5.ogg
./assets/audio/sfx/ouch6.ogg
./assets/audio/sfx/ouch7.ogg
./assets/audio/sfx/ouch8.ogg
./assets/audio/sfx/ouch9.ogg
./assets/audio/sfx/ouch10.ogg
./assets/audio/sfx/ouch11.ogg
Next, we need to tell Flutter that we want these new files to be included in the asset bundle during compilation. So open up ./pubspec.yaml
and add the following block of code under the asset
section (the asset
section has been included for reference).
assets:
- assets/audio/sfx/haha1.ogg
- assets/audio/sfx/haha2.ogg
- assets/audio/sfx/haha3.ogg
- assets/audio/sfx/haha4.ogg
- assets/audio/sfx/haha5.ogg
- assets/audio/sfx/ouch1.ogg
- assets/audio/sfx/ouch2.ogg
- assets/audio/sfx/ouch3.ogg
- assets/audio/sfx/ouch4.ogg
- assets/audio/sfx/ouch5.ogg
- assets/audio/sfx/ouch6.ogg
- assets/audio/sfx/ouch7.ogg
- assets/audio/sfx/ouch8.ogg
- assets/audio/sfx/ouch9.ogg
- assets/audio/sfx/ouch10.ogg
- assets/audio/sfx/ouch11.ogg
Again, with ./pubspec.yaml
file, you must mind the indentation or leading spaces as they control which section an item is under.
Then let’s go to ./lib/main.dart
and add the following block inside the main
function:
Flame.audio.disableLog();
Flame.audio.loadAll(<String>[
'sfx/haha1.ogg',
'sfx/haha2.ogg',
'sfx/haha3.ogg',
'sfx/haha4.ogg',
'sfx/haha5.ogg',
'sfx/ouch1.ogg',
'sfx/ouch2.ogg',
'sfx/ouch3.ogg',
'sfx/ouch4.ogg',
'sfx/ouch5.ogg',
'sfx/ouch6.ogg',
'sfx/ouch7.ogg',
'sfx/ouch8.ogg',
'sfx/ouch9.ogg',
'sfx/ouch10.ogg',
'sfx/ouch11.ogg',
]);
Breakdown: The first line disables extra debug logging so that it does not write too much log information into the debug console. If you have no idea what this is, you can ignore it, for now. I’ll try to discuss this in a separate article in the future.
The next lines are actually just one line, a function call that has a parameter that is an array. The values of the array have been spread vertically for readability. This preloads all the sound effects files so that they are cached and ready to be played by the game anytime.
You might have noticed that this is the same format as preloading the images.
The next step is to play the sounds at the appropriate places.
We basically have two sound effects. An “ouch” and a “haha”. The ouch sound is played when a player taps a fly and kills it. The haha sound is played every time the player loses.
You might be wondering why we have eleven hahas and five ouches.
Let me share a little game development secret
Anything repetitive is boring. I get it, most of the things in life is repetitive (breathing, days, game loop), but we can make our game’s sounds a little less boring by using different versions of that sound effect every time it’s played. If we play the same sound every time the player kills a fly, it could get really boring really fast. We might as well skip adding sound effects.
To do this every time we need to play a sound we get a random number (from game
‘s rnd
variable) and play that corresponding variation of “the same” sound.
Let’s open up ./lib/components/fly.dart
and import Flame into this file:
import 'package:flame/flame.dart';
Then add the following block of code inside the onTapDown
handler, right after checking if the tapped fly is not yet dead.
Flame.audio.play('sfx/ouch' + (game.rnd.nextInt(11) + 1).toString() + '.ogg');
Breakdown: We just call the play
method of Flame’s audio
library passing in the filename of the audio file we want to play.
Let me get into detail with the randomizer part — (game.rnd.nextInt(11) + 1)
. The function nextInt
takes in an integer parameter and returns and integer with a random value from zero up to (but not including) the passed parameter. So if we pass 11
, we could get any number from 0
to 10
. We then add 1
to it so the returned set becomes 1
to 11
matching our filenames (ouch**1**.ogg
, ouch**2**.ogg
, …, ouch**11**.ogg
).
If you try the game right now, you’ll notice that flies now “react verbally” when you tap them. It’s impossible to demo this using a screenshot so I’ll reserve the demo to the final video demo.
Let’s now work on the sound effects for losing a game.
Open up ./lib/langaw-game.dart
and modify code inside the if
block that checks if the player “is playing” and just tapped on the screen but missed a fly.
if (activeView == View.playing && !didHitAFly) {
Flame.audio.play('sfx/haha' + (rnd.nextInt(5) + 1).toString() + '.ogg');
activeView = View.lost;
}
Breakdown: We’ve added the same call to play a sound effect but pass in the “haha” file randomized to five variations.
We also have one other losing condition so let’s take care of that by opening ./lib/components/callout.dart
and importing Flame to the file.
import 'package:flame/flame.dart';
Inside the update
method, modify the if
block that checks if value
is now less than or equal to zero so it looks like the following:
if (value <= 0) {
Flame.audio.play('sfx/haha' + (fly.game.rnd.nextInt(5) + 1).toString() + '.ogg');
fly.game.activeView = View.lost;
}
Breakdown: It’s basically the same code as above, the difference is how we access the game’s rnd
object. We don’t have a direct reference to the game
instance in this class so we pass through the fly
reference first, then fly
‘s game
reference, then on to the rnd
object.
Try running the game now. Again, the demo will be in the final video demo at the end of this part.
View the code at this step on GitHub.
Step 4: Background music
Let’s add in some background music while we’re at it.
BGM (or background music) sets the mood for the game or the current screen the player is on. For this game, we’ll have two different BGMs one for the playing screen and another for all the others.
First, let’s copy the files inside ./audio/bgm
in the resource pack into our game directory and place them in ./lib/assets/audio/bgm
to have a file structure that looks like this:
./assets
./assets/audio
./assets/audio/bgm
./assets/audio/bgm/home.mp3
./assets/audio/bgm/playing.mp3
These files need to be included in the bundle too so let’s include them in ./pubspec.yaml
assets section (the assets
and the first sfx
lines are included for reference):
assets:
- assets/audio/bgm/home.mp3
- assets/audio/bgm/playing.mp3
- assets/audio/sfx/haha1.ogg
BGM files will be looped so let’s just include them in the preload list. Let’s open ./lib/main.dart
and include the BGM files in the array passed to Flame.audio.loadAll
:
Flame.audio.loadAll([
'bgm/home.mp3',
'bgm/playing.mp3',
'sfx/haha1.ogg',
With SFX (or sound effects), we can just play them and forget about them since they play only once. With BGMs though we need to have a reference to their players so we can control them with operations like pause
, resume
, and seek
.
We’ll store the variables that will keep these references in the game class so let’s open up ./lib/langaw-game.dart
.
First thing’s first, we need access to the AudioPlayer
class so let’s import that first:
import 'package:audioplayers/audioplayers.dart';
Next, we need instance variables that will hold the reference to the audio players for each of the BGM files.
AudioPlayer homeBGM;
AudioPlayer playingBGM;
Inside the initialize
method, we initialize these variables, pause the players, and play the home BGM using the following block:
homeBGM = await Flame.audio.loop('bgm/home.mp3', volume: .25);
homeBGM.pause();
playingBGM = await Flame.audio.loop('bgm/playing.mp3', volume: .25);
playingBGM.pause();
playHomeBGM();
Breakdown: The first line gets an instance of an AudioPlayer
with the passed filename loaded. We’re using the loop
method so this will immediately start playing the BGM. We cancel this effect by immediately following it with homeBGM.pause()
which should be self-explanatory.
Important note: If you’re using the most recent versions of Flame (0.11.0
I believe), you will need to use the loopLongAudio
method instead of loop
in the code block above and anywhere else where loop
is used.
Thanks to Bako and Scott for bringing this to my attention in the comments section.
Also, please check out the new guide for managing and playing background music tracks.
You may notice that we have the volume set to .25
which is one-fourth of its original volume. This is because of personal gaming experience where the music is just too loud and it drowns more important aspects of the game. Feel free to play with this value. Valid values are from 0
(muted) to 1
(full volume).
The next two lines do the same thing but for the playing BGM.
Lastly, we call playHomeBGM()
(a method we’re yet to write) to start playing the home BGM. Let’s write this function and it’s playing equivalent:
void playHomeBGM() {
playingBGM.pause();
playingBGM.seek(Duration.zero);
homeBGM.resume();
}
void playPlayingBGM() {
homeBGM.pause();
homeBGM.seek(Duration.zero);
playingBGM.resume();
}
Breakdown: These two methods do the same thing but one is for home BGM and the other is for playing BGM. They’re basically opposites or mirrors of each other.
With playHomeBGM
, we pause the playingBGM
and set its position back to the beginning (Duration.zero
). On the other hand, playPlayingBGM
does the same thing but swap homeBGM
and playingBGM
.
Every time the player loses, we should revert back to the homeBGM
, so inside the onTapDown
handler and inside the miss condition (just below playing the “haha” SFX), add the following line to pause and reset the playingBGM
and play the homeBGM
.
playHomeBGM();
Then quickly jump over to ./lib/components/callout.dart
and add the following line inside the lose condition (if
block to check if value
is less than or equal to zero) just below the line that plays the “haha” SFX:
fly.game.playHomeBGM();
Finally, the last step is to play the playing BGM when starting a game (tapping the start button) so let’s open up ./lib/components/start-button.dart
and add the following line just before closing the onTapDown
method:
game.playPlayingBGM();
It’s time to run the game and listen to the awesome BGMs our game now has. View the final demo video below to listen if you’re not yet following along with your own code.
View the code at this step on GitHub.
Step 5: BGM and SFX control
It’s generally nice to have sound and music in the game, but it’s not for everyone (or every situation). There are times where you just want to play a game in silence.
To do this we will provide our players with two buttons, one mutes the music and the other mutes the sound effects.
Control buttons
The two buttons we’ll be making will have two states: enabled and disabled. We already have graphical representations for both buttons and both states (four in all) in the resource pack.
Let’s copy the remaining icons from the ./images/ui/
folder of the resource pack to our assets directory in ./assets/images/ui/
to have a file structure that looks like this:
./assets
./assets/images
./assets/images/ui
./assets/images/ui/icon-music-disabled.png
./assets/images/ui/icon-music-enabled.png
./assets/images/ui/icon-sound-disabled.png
./assets/images/ui/icon-sound-enabled.png
Just like all the other assets, we need to add these files to ./pubspec.yaml
‘s assets
section to let Flutter know we want these files to be included in the build bundle.
- assets/images/ui/icon-music-disabled.png
- assets/images/ui/icon-music-enabled.png
- assets/images/ui/icon-sound-disabled.png
- assets/images/ui/icon-sound-enabled.png
Then let’s go to ./lib/main.dart
to preload these icons. Add the following lines to the array of filenames that are passed to the call to Flame.images.loadAll
:
'ui/icon-music-disabled.png',
'ui/icon-music-enabled.png',
'ui/icon-sound-disabled.png',
'ui/icon-sound-enabled.png',
Now we’ll create the buttons and add them to the game class. These buttons are similar to the help and credits buttons with minor differences.
Create the class file ./lib/components/music-button.dart
and add write the following class:
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:langaw/langaw-game.dart';
class MusicButton {
final LangawGame game;
Rect rect;
Sprite enabledSprite;
Sprite disabledSprite;
bool isEnabled = true;
MusicButton(this.game) {
rect = Rect.fromLTWH(
game.tileSize * .25,
game.tileSize * .25,
game.tileSize,
game.tileSize,
);
enabledSprite = Sprite('ui/icon-music-enabled.png');
disabledSprite = Sprite('ui/icon-music-disabled.png');
}
void render(Canvas c) {
if (isEnabled) {
enabledSprite.renderRect(c, rect);
} else {
disabledSprite.renderRect(c, rect);
}
}
void onTapDown() {
if (isEnabled) {
isEnabled = false;
game.homeBGM.setVolume(0);
game.playingBGM.setVolume(0);
} else {
isEnabled = true;
game.homeBGM.setVolume(.25);
game.playingBGM.setVolume(.25);
}
}
}
Breakdown: Since this is similar to the help and credits buttons, I’ll only break down the different parts.
Instead of keeping just one sprite
variable to store the button’s sprite. We have two variables, one that holds the enabled sprite and one that holds the disabled sprite. The button is positioned on the upper-left corner of the screen with it’s left and top edges at one-fourth of a tile away from the left and top edges of the screen, respectively.
We also have another variable called isEnabled
which is a bool
ean meaning it can hold either true
or false
. This variable can be manipulated to toggle the state of the button and to render the appropriate sprite as can be seen inside the render
method.
The most important difference though, is the onTapDown
handler. There’s an if
block that checks if isEnabled
is set to true
. If it is, the value is flipped to false
and the volume of both homeBGM
and playingBGM
(in game
instance) is set to zero. If isEnabled
is already set to false
, it toggles the value back to true
and set’s the volume of both BGMs back to .25
(the initial value).
Let’s create the other control button at ./lib/components/sound-button.dart
with the following code:
import 'dart:ui';
import 'package:flame/sprite.dart';
import 'package:langaw/langaw-game.dart';
class SoundButton {
final LangawGame game;
Rect rect;
Sprite enabledSprite;
Sprite disabledSprite;
bool isEnabled = true;
SoundButton(this.game) {
rect = Rect.fromLTWH(
game.tileSize * 1.5,
game.tileSize * .25,
game.tileSize,
game.tileSize,
);
enabledSprite = Sprite('ui/icon-sound-enabled.png');
disabledSprite = Sprite('ui/icon-sound-disabled.png');
}
void render(Canvas c) {
if (isEnabled) {
enabledSprite.renderRect(c, rect);
} else {
disabledSprite.renderRect(c, rect);
}
}
void onTapDown() {
isEnabled = !isEnabled;
}
}
Breakdown: You’ll notice that this is almost exactly like the MusicButton
class with its position is set to the upper-left corner of the screen but a little to the right. Also, the onTapDown
handler is just a simple flip/toggle of the boolean value. This is because we don’t need to mute the SFX players due to their “play and forget” nature.
For these buttons to show up in the game, we need to add them to the game class. Let’s open up ./lib/langaw-game.dart
and import the buttons we just created:
import 'package:langaw/components/music-button.dart';
import 'package:langaw/components/sound-button.dart';
Then the instance variables that will hold instances of our button classes:
MusicButton musicButton;
SoundButton soundButton;
We need to initialize these buttons inside the initialize
method, below help and credits button initialization is fine:
musicButton = MusicButton(this);
soundButton = SoundButton(this);
Next, we need to call their render
methods from the game class’ own render
method. Please have these buttons rendered last (but before dialog boxes) so that they’re available anytime in the game except when viewing the help and credits dialog boxes:
musicButton.render(canvas);
soundButton.render(canvas);
Lastly, we need to forward the tap down events to the button’s onTapDown
handlers. Remember that objects on top should receive tap events first. Since our buttons will be behind the dialog boxes, the hit test for these buttons should be just below the check for dialog boxes tap down handler.
// music button
if (!isHandled && musicButton.rect.contains(d.globalPosition)) {
musicButton.onTapDown();
isHandled = true;
}
// sound button
if (!isHandled && soundButton.rect.contains(d.globalPosition)) {
soundButton.onTapDown();
isHandled = true;
}
Sound effects control
The MusicButton
class is already handling the volume of the BGMs. The only thing missing now is having the SoundButton
‘s state affect the actual SFX.
What we’ll want to do is check the state of the sound button before trying to play a sound effect. This can easily be done by enclosing the “play” call with an if
block that checks if the soundButton
‘s isEnabled
property is set to true
.
We have to change three places to achieve this, first in ./lib/langaw-game.dart
(inside the onTapDown
handler):
if (soundButton.isEnabled) {
Flame.audio.play('sfx/haha' + (rnd.nextInt(5) + 1).toString() + '.ogg');
}
Second, in ./lib/components/callout.dart
(inside the update
method):
if (fly.game.soundButton.isEnabled) {
Flame.audio.play('sfx/haha' + (fly.game.rnd.nextInt(5) + 1).toString() + '.ogg');
}
And last, in ./lib/components/fly.dart
(inside the onTapDown
handler):
if (game.soundButton.isEnabled) {
Flame.audio.play('sfx/ouch' + (game.rnd.nextInt(11) + 1).toString() + '.ogg');
}
View the code at this step on GitHub.
Gameplay test
Here’s a demo of game’s current state. If you’ve been following along, you should have something that looks the same.
Conclusion
That should take care of all the goals we had for this part. We have a scoring system, high-score storage, sound effects, background music, and buttons that mute the SFX and BGM.
Before moving on, let me present a challenge. There’s one little feature that I intentionally left out. The state of the sound and music control buttons reset to “enabled” every time the player launches the game.
By this time, you should have enough knowledge and experience to modify the buttons so that their states are saved. So try it out.
If you find it difficult, don’t hesitate to contact me by sending me an email or dropping a comment below. You could also join my Discord server so we can communicate in real time.
What’s next
I highly recommend checking out the new Background Music guide available here before continuing to the last part.
The final part is next. We’ll be addressing some bugs and publishing our game to Google Play Store. I hope to see you on the finish line!