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:

  1. Introduction to game development
  2. Set up a playable game
  3. Graphics and animation
  4. Views and dialog boxes
  5. Scoring, storage, and sound (you are here)
  6. Finishing up and packaging

Prerequisites

  1. All the requirements from the previous part.
  2. 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.
  3. 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 Shadows 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 Futures 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:

  1. The player is currently playing (the view is set to “playing”)
  2. 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 boolean 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!