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:
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 { @override _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 @override 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', ), ), ], ), ), ); }
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 { @override _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 @override 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 the Container’s foregroundDecoration property.
- Give the Container a round shadow in the foregroundDecoration 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> { //... @override 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> { // ... @override 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 inside onTap of the GestureDetector in bubble.dart:
class _BubbleState extends State<Bubble> { // ... final AudioCache player = AudioCache(); @override void dispose() { player.clearCache(); color = null; super.dispose(); } void _playSound() { player.play('pop.mp3'); } @override 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> { // ... @override 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> { // ... @override void initState() { super.initState(); ruleColorName = colours.keys.elementAt(random.nextInt(colours.length)); } @override 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({ @required this.parentAction, @required this.index, //... }); } class _BubbleState extends State<Bubble> { // ... @override 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({@required 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 @override 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 { @override 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; @override 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!
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); }, ),