Building an Infinite Bubble Popping Game in Flutter
Use basic GestureDetector to build a never ending Bubble Popping Game with custom game rules and configurations.
TL;DR I made a game in Flutter with basic Flutter widgets, dynamic rule generation and infinite levels using SharedPreferences
for the levels and audioplayers
for the ‘pop’ sound effect (yes, these were the only 3rd party dependencies used). The entire game was made in 1 day and with very less code.
Perfect starting point for Flutter Developers aspiring to try Game Development in Flutter.
This tutorial will be teaching you how to create this simple Bubble Popping Game in Flutter with infinite levels and custom game rules:
Getting Started
Let’s start off by creating a single Bubble with some colour and number that one can ‘pop’.
Breaking it down, we can see that our bubble:
- will be a custom stateful widget (stateful if you want to add in a ‘popping’ animation, stateless otherwise),
- can be designed as basic Container with some radius, color and child (for the number),
- can be wrapped inside a GestureDetector to handle what happens if one taps on this Bubble.
Create a new file, bubble.dart
and create a stateful widget as follows:
class Bubble extends StatefulWidget {
final Color ruleColour, colour;
final int ruleNumber, number;
final String colorName;
Bubble({
this.ruleColour,
this.colour,
this.colorName,
this.ruleNumber,
this.number,
});
_BubbleState createState() => _BubbleState();
}
class _BubbleState extends State<Bubble> {
double width = 80;
Color color;
void initState() {
super.initState();
color = widget.colour;
}
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
print('popped!');
},
child: Container(
child: widget.number != null
? Center(
child: Text(
widget.number.toString(),
style: TextStyle(fontSize: 20),
),
)
: Container(),
height: width,
width: width,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(40),
),
),
);
}
}
Because we need to have a screen showing the current Level, Rule and multiple Bubbles, create another file, screen.dart
with a Stateful widget - BubbleScreen
, and call the Bubble we just created, inside a Column
:
class BubbleScreen extends StatefulWidget {
_BubbleScreenState createState() => _BubbleScreenState();
}
class _BubbleScreenState extends State<BubbleScreen> {
int level = 0;
String ruleColorName; // name of the colour that needs to be popped, e.g. red
int ruleNumber; // numbered bubbles that need to be popped, e.g. multiples of 4
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Play!'),
),
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 30),
child: Column(
children: <Widget>[
Text(
'Level ' + (level ?? 0).toString(),
style: TextStyle(fontSize: 22),
),
Text(
'Pop the Reds',
style: TextStyle(fontSize: 22),
),
SizedBox(
height: 150,
),
Center(
child: Bubble( // create a Bubble
colour: Colors.red,
ruleColour: Colors.red,
colorName: 'Reds',
),
),
],
),
),
);
}
So far, so good, but this looks pretty bland and not like an actual bubble, right?
There are 3 ways to achieve a bubble look:
- Store a 3d shine effect as an image in your project, and use it in the
image
property of theContainer’s
foregroundDecoration
property. - Give the
Container
a round shadow in theforegroundDecoration
property. - Wrap the Container inside a
Stack
, and Position a smaller, white Circle as the Stack’s 2nd child, such that it appears over the Bubble, giving it a white shiny effect.
We’ll use the 2nd approach here:
class _BubbleState extends State<Bubble> {
//...
Widget build(BuildContext context) {
return GestureDetector(
//...
child: Container(
// ...
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
boxShadow: [
BoxShadow(
color: Color(0xFFFFFFFF),
blurRadius: 25,
spreadRadius: -10,
offset: Offset(
10,
-20,
),
),
],
),
),
);
}
}
Perfect, it looks more 3d-ish now!
With the current code, if you tap this Bubble, a simple ‘popped!’ message gets printed on the Console. In its place, we need to:
- perform an animation that shows the Bubble disappearing, and
- play a ‘pop’ sound.
Performing Animation
There are multiple ways to animate your Bubble popping, but the simplest is using an AnimatedContainer
instead of the regular Container
!
Simply replace your Container
with an AnimatedContainer
, and add your choice of duration
and curve
properties - and voila. Flutter will animate your Container
for you!
Additionally, we want to trigger this animation when one taps the Bubble, so let’s write some code inside our GestureDetector’s
onTap
function:
class _BubbleState extends State<Bubble> {
// ...
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
width = 0; // because the bubble needs to disappear
color = Colors.white.withOpacity(0.5);
});
},
child: AnimatedContainer(
child: widget.number != null
? Center(
child: Text(
widget.number.toString(),
style: TextStyle(fontSize: 20),
),
)
: Container(),
height: width,
width: width,
duration: Duration(milliseconds: 350), // how long should the animation take to complete
curve: Curves.fastOutSlowIn, // type of animation
decoration: // ...
foregroundDecoration: // ...
),
);
}
}
Now, when you tap this Bubble, you can see it getting smaller until it vanishes. Pretty cool, right?
Adding the ‘pop’ Sound Effect
- Using the audioplayers plugin, add the dependency to your
pubspec.yaml
:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.3
audioplayers: ^0.15.1
download this mp3 file and paste it in your
assets/
directory.Configure your
pubspec.yaml
to allow consuming this asset in your code:
flutter:
uses-material-design: true
assets:
- assets/
- Initialise the
AudioCache
object and configure to play pop.mp3 insideonTap
of theGestureDetector
inbubble.dart
:
class _BubbleState extends State<Bubble> {
// ...
final AudioCache player = AudioCache();
void dispose() {
player.clearCache();
color = null;
super.dispose();
}
void _playSound() {
player.play('pop.mp3');
}
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
_playSound();
setState(() {
// ...
});
},
child: // ...
);
}
}
Perform a Cold Start of your app to ensure the dependency and file are correctly added and try popping that bubble to hear the newly added sound!
Configuring the BubbleScreen
Congratulations for reaching this far! 🥳
Now that we’ve configured our Bubble
, it’s time to configure our BubbleScreen
.
Let’s break down the components and functionalities that we need our BubbleScreen
to have:
- a grid of Bubbles with random colours
- a random colour name that appears at the top
- a method that checks if the player tapped the right coloured Bubble or not
Creating a Grid of Bubbles with Random Colours
Let’s create a Map of the only possible colours our Bubbles can have.
Each entry will be a ColorState
object mapped to a String which will be that colour’s name, and each ColorState
object will have a Color and count of how many occurrences of this particular Color the Bubbles have altogether:
class ColorState {
final Color color;
int _count = 0;
ColorState(this.color);
void incrementCount() {
_count++;
}
void decrementCount() {
_count--;
}
void resetCount() {
_count = 0;
}
int get count => _count;
}
class _BubbleScreenState extends State<BubbleScreen> {
final random = Random();
final Map<String, ColorState> colours = {
'Reds': ColorState(Colors.red), // 336
'Purples': ColorState(Colors.purple), // 27b0
'Yellows': ColorState(Colors.yellow), // eb3b
'Blues': ColorState(Colors.cyan), // bcd4
'Greens': ColorState(Colors.lightGreenAccent), // ff59
};
// ...
}
Now let’s set up our Grid of Bubbles, passing in a random Color from the colours’ Map that we have just created:
class _BubbleScreenState extends State<BubbleScreen> {
// ...
Widget build(BuildContext context) {
return Scaffold(
// ...
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 30),
child: Column(
children: <Widget>[
// ...
GridView.builder(
physics: NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: bubbles.length,
padding: const EdgeInsets.symmetric(vertical: 20),
itemBuilder: (context, index) {
int randColorIndex = random.nextInt(colours.length); // obtain a random colour's index from the map
Color randColor = colours.values
.elementAt(randColorIndex)
.color; // get that random Color
String colorName = colours.keys.elementAt(randColorIndex); // get that random colour's name
return Bubble(
ruleColour: Colors.red,
colour: randColor,
colorName: colorName,
index: index,
);
},
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
),
),
],
),
),
);
}
}
Notice if you tap on any Bubble, it does ‘vanish’, but the Bubble is technically still there in the Grid if you pass it a number too. This is because even though the Container’s width was set as 0, the Container is still there because of its child, the number. We’ll fix this in just a moment.
Setting a Random Color as the Rule Color for the Game
In order to play the Game, we need to set a rule Color the player’s move should match. This Color also needs to appear at the top where it says ‘Pop the Reds’.
Similar to what we did earlier, we’ll assign a random String to the ruleColorName
variable - this will be one of the keys from the Map we created earlier. This will then be displayed at the top and also passed to the Bubble in our Grid, so we can evaluate a correct or incorrect Move:
class _BubbleScreenState extends State<BubbleScreen> {
// ...
void initState() {
super.initState();
ruleColorName = colours.keys.elementAt(random.nextInt(colours.length));
}
Widget build(BuildContext context) {
return Scaffold(
// ...
body: Padding(
padding: const EdgeInsets.symmetric(vertical: 30),
child: Column(
children: <Widget>[
// ...
Text(
'Pop the $ruleColorName',
style: TextStyle(fontSize: 22),
),
GridView.builder(
// ...
itemBuilder: (context, index) {
// ...
return Bubble(
ruleColour: colours[ruleColorName].color,
// ...
);
},
),
],
),
),
);
}
}
Updating the Screen’s UI with Every Move
In order to track whether a player made a correct or incorrect move, we need a communication mechanism that allows the child (Bubble) to notify its parent (BubbleScreen) of the Move a player just made.
We’ll do this via a custom callback. But first, let’s create our Move
class to configure the data BubbleScreen
would need in order to update its UI:
class Move {
final String colorName;
final int index;
final bool isCorrectMove;
Move(this.colorName, this.index, this.isCorrectMove);
}
Next, create a bool variable called correctMove
in BubbleScreen
; we’ll use this to update the UI with a ‘Game Over’ or ‘Level Up’ message.
Now, let’s create a method to track and update the Move in BubbleScreen. This method will check if correctMove
is false
, the ‘Game Over’ UI should be rendered:
void _updateMove(Move currentMove) {
setState(() {
correctMove = currentMove.isCorrectMove;
});
if (!correctMove)
_gameOver();
}
void _gameOver() {
print('Game Over!');
setState(() {
gameOver = true; // flag to show 'Game Over' Text on the screen
showOverlay = true; // flag to show an overlay over the screen/grid
});
}
void _gameWon() async {
print('Level Up!');
setState(() {
showOverlay = true;
});
}
Next, pass this callback inside the Bubble’s constructor like this:
GridView.builder(
// ...
itemBuilder: (context, index) {
return Bubble(
// ...
parentAction: _updateMove,
index: index,
);
},
With this, the parent is all set up to receive updates from the child. Time to configure the child now.
Create a variable for the parent’s callback function we just implemented, add it as a required parameter to the Bubble’s constructor, and pass the Move object with this callback inside the GestureDetector’s onTap
method:
class Bubble extends StatefulWidget {
// ...
final ValueChanged<Move> parentAction;
final int index;
Bubble({
this.parentAction,
this.index,
//...
});
}
class _BubbleState extends State<Bubble> {
// ...
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
//...
Move move = Move(widget.colorName, widget.index,
widget.colour == widget.ruleColour);
widget.parentAction(move);
},
// ...
}
}
Run the app and make a wrong move (if the rule says ‘Pop the Reds’, try popping a Yellow Bubble) and see the message ‘Game Over’ printed in the console.
Alright, that’s awesome!
But wait, it shows the ‘Game Over’ message for any incorrect Move, what about ‘Level Up’?
Let’s do that next.
Configuring Counters and Adding BubbleState
Let’s say the rule is to pop the Reds, and the Grid has 5 Red Bubbles. If a player pops a Yellow Bubble, our callback will print a ‘Game Over’ message.
But if the player pops a Red Bubble, our callback should not print any message, allowing the player to keep playing the Game. When the player has popped all 5 Red Bubbles in succession, the callback should print a ‘Level Up’ message.
It’s clear that we need to keep a count of how many Bubbles the player has popped and the count of each Color inside our colours’ Map.
To hold the count of each Color in our Map, we need to slightly modify our logic for creating Bubbles within our Grid. This new logic will also fix the bug of a Bubble still existing in the Grid, despite being popped - that we encountered earlier.
First, we need to create a List
in initState()
which we will use to populate our Grid with Bubbles.
Since our Bubble is a widget with a lot of data, we can’t make a List of Bubbles as it will overwhelm the memory. Instead, let’s create a List
of BubbleState
which will have 2 attributes: an int colorIndex
corresponding to our colours’ Map, and a boolean isActive
, which we will use to control what happens to a Bubble that’s already popped:
class BubbleState {
final int colorIndex;
bool isActive = true;
BubbleState({ this.colorIndex});
}
class _BubbleScreenState extends State<BubbleScreen> {
// ...
List<BubbleState> bubbles = [];
int ruleCount; // count of bubbles that need to be popped according to the Rule
bool correctMove = true, showOverlay = false, gameOver = false;
int popped = 0; // count of bubbles the player has popped
void initState() {
super.initState();
_loadGame();
}
void _loadGame() {
ruleColorName = colours.keys.elementAt(random.nextInt(colours.length));
bubbles = List.generate(
17,
(index) => BubbleState(
colorIndex: random.nextInt(colours.length), // random colour indices from the colours Map
),
);
colours.values.forEach((element) {
element.resetCount(); // to ensure all counts are 0
});
// set the count for each colour index generated inside bubbles
bubbles.forEach(
(item) => colours.values.elementAt(item.colorIndex).incrementCount(),
);
ruleCount = colours[ruleColorName].count; // get the final count of the Rule Colour
// colours.values.forEach((element) {
// print(element.count);
// });
}
void _updateMove(Move currentMove) {
setState(() {
correctMove = currentMove.isCorrectMove;
popped++;
bubbles[currentMove.index].isActive = false;
colours[currentMove.colorName].decrementCount();
});
if (!correctMove)
_gameOver();
else if (correctMove && popped == ruleCount) _gameWon();
}
}
Next, modify the GridView.builder
as follows:
GridView.builder(
// ...
itemCount: bubbles.length,
itemBuilder: (context, index) {
Color randColor = colours.values
.elementAt(bubbles[index].colorIndex)
.color;
String colorName =
colours.keys.elementAt(bubbles[index].colorIndex);
return bubbles[index].isActive
? Bubble(
ruleColour: colours[ruleColorName].color,
colour: randColor,
colorName: colorName,
parentAction: _updateMove,
index: index,
)
: Container();
},
),
Super! Time to add in the infinite Levels now.
Adding Infinite Levels
Upon Game Over or Level Up, the player should see a Button with which he can play the respective Level.
Using the showOverlay
and gameOver
flags, add a glassy overlay with the respective message and Button over the GridView.builder
, by wrapping it all in a Stack
:
return Scaffold(
appBar: // ...
body: Stack(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(vertical: 30),
child: Column(
children: <Widget>[
// ...
GridView.builder(
// ...
),
],
),
),
Visibility(
visible: showOverlay,
child: Container(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade200.withOpacity(0.5)),
),
),
),
),
Align(
alignment: Alignment.center,
child: Visibility(
visible: showOverlay,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
gameOver ? 'GAME OVER' : 'LEVEL UP!',
style: TextStyle(fontSize: 40),
),
FloatingActionButton.extended(
label: Text(gameOver ? 'Play Again' : 'Next Level'),
onPressed: () {
print('rebuilding..');
},
),
],
),
),
),
],
),
);
When the player presses this Button, we need to reset our List of BubbleState, flags, counts, and in short - everything. The simplest approach for this is to just rebuild the screen all over again. Give your BubbleScreen a unique id, configure its route inside main.dart
, and rebuild the screen in the easiest way:
class BubbleScreen extends StatefulWidget {
static String id = 'bubble_screen';
}
FloatingActionButton.extended(
label: Text(gameOver ? 'Play Again' : 'Next Level'),
onPressed: () {
print('rebuilding..');
Navigator.pushReplacementNamed(context, BubbleScreen.id);
},
),
Configuring routes inside main.dart
:
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
title: 'Pop It',
debugShowCheckedModeBanner: false,
initialRoute: BubbleScreen.id,
routes: {
BubbleScreen.id: (context) => BubbleScreen(),
},
);
}
}
Alright! Now the finishing touches - maintaining the Levels.
To display the appropriate Level at the top, we can make use of SharedPreferences.
Add the dependency to your pubspec.yaml
file:
shared_preferences: ^0.5.8
Create a method to fetch the Level value from SharedPreferences
, and if it doesn’t exist, simply set it to 0:
Future<void> _loadLevel() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
level = prefs.getInt('level') ?? 0;
}
Declare a Future
variable and initialise it with this method inside initState()
to ensure reading the value just once from SharedPreferences
:
class _BubbleScreenState extends State<BubbleScreen> {
// ...
Future<void> loadLevelFuture;
void initState() {
super.initState();
loadLevelFuture = _loadLevel();
_loadGame();
}
// ...
}
Next, wrap your Level Text inside a FutureBuilder
:
FutureBuilder<void>(
future: loadLevelFuture,
builder: (context, snapshot) {
return Text(
'Level ' + (level != null ? level.toString() : ''),
style: TextStyle(fontSize: 22),
);
}
),
Finally, update the Level inside _gameWon()
, so the player is advanced to the next level:
Future<void> _gameWon() async {
print('Woohoo, you won!!');
SharedPreferences prefs = await SharedPreferences.getInstance();
prefs.setInt('level', level + 1);
setState(() {
showOverlay = true;
});
}
And there you have it, a simple level based Game in Flutter with infinite levels!
The full source code has implementations for ‘Pop the Multiples of x’ (where x is a random number) and ‘Pop the y that are Multiples of x’ (where y is a random colour and x is a random number).
Check it out here to see how a random Game Rule is generated for a random Level!