Introduction to Flame with Flutter
1. Introduction
Flame is a Flutter-based 2D game engine. In this codelab, you will build a game inspired by one of the classics of '70s video games, Steve Wozniak'sBreakout. You will use Flame's Components, to draw the bat, ball, and bricks. You will utilize Flame's Effects to animate the bat's movement and see how to integrate Flame with Flutter's state management system.
When complete, your game should look like this animated gif, albeit a tad slower.
What you'll learn
- How the basics of Flame work, starting with
GameWidget
. - How to use a game loop.
- How Flame's
Component
s work. They are akin to Flutter'sWidget
s. - How to handle collisions.
- How to use
Effect
s to animateComponent
s. - How to overlay Flutter
Widget
s on top of a Flame game. - How to integrate Flame with Flutter's state management.
What you'll build
In this codelab, you're going to build a 2D game using Flutter and Flame. When complete, your game should meet the following requirements:
- Function on all six platforms that Flutter supports: Android, iOS, Linux, macOS, Windows, and the web
- Maintain at least 60 fps using Flame's game loop.
- Use Flutter capabilities like the
google_fonts
package andflutter_animate
to recreate the feel of 80s arcade gaming.
Note: If this is your first time working with Flutter, complete theYour first Flutter app codelab first. That codelab configures your Flutter development environment and gets you started with working with Flutter.
2. Set up your Flutter environment
Editor
To simplify this codelab, it presumes thatVisual Studio Code (VS Code) is your development environment. VS Code is free and works on all major platforms. We use VS Code for this codelab because the instructions default to VS Code-specific shortcuts. The tasks become more straightforward: "click this button" or "press this key to do X" rather than "do the appropriateaction in your editor to do X".
You can use any editor you like: Android Studio, other IntelliJ IDEs, Emacs, Vim, or Notepad++. They all work with Flutter.
Choose a development target
Flutter produces apps for multiple platforms. Your app can run on any of the following operating systems:
- iOS
- Android
- Windows
- macOS
- Linux
- web
It's common practice to choose one operating system as your development target. This is the operating system that your app runs on during development.
For example: say you're using a Windows laptop to develop your Flutter app. You then choose Android as your developmenttarget. To preview your app, you attach an Android device to your Windows laptop with a USB cable and your app-in-development runs on that attached Android device, or in an Android emulator. You could have chosen Windows as the development target, which runs your app-in-development as a Windows app alongside your editor.
Tip: Choose your development device's Operating System as your development target. For example, if your computer runs Windows, choose Windows as the development target.
You might be tempted to choose the web as your development target. This has a downside during development: you lose Flutter'sStateful Hot Reload capability. Flutter can't currently hot-reload web applications.
Make your choice before continuing. You can always run your app on other operating systems later. Choosing a development target makes the next step smoother.
Install Flutter
The most up-to-date instructions on installing the Flutter SDK can be found ondocs.flutter.dev.
The instructions on the Flutter website cover the installation of the SDK and the development target-related tools and the editor plugins. For this codelab, install the following software:
- Flutter SDK
- Visual Studio Code with the Flutter plugin
- Compiler software for your chosen development target. (You needVisual Studio to target Windows orXcode to target macOS or iOS)
In the next section, you'll create your first Flutter project.
If you need to troubleshoot any problems, you might find some of these questions and answers (from StackOverflow) helpful for troubleshooting.
Frequently Asked Questions
- How do I find the Flutter SDK path?
- What do I do when the Flutter command is not found?
- How do I fix the "Waiting for another flutter command to release the startup lock" issue?
- How do I tell Flutter where my Android SDK installation is?
- How do I deal with the Java error when running
flutter doctor --android-licenses
? - How do I deal with Android
sdkmanager
tool not found? - How do I deal with the "
cmdline-tools
component is missing" error? - How do I run CocoaPods on Apple Silicon (M1)?
- How do I disable auto formatting on save in VS Code?
3. Create a project
Create your first Flutter project
This involves opening VS Code and creating the Flutter app template in a directory you choose.
- Launch Visual Studio Code.
- Open the command palette (
F1
orCtrl+Shift+P
orShift+Cmd+P
) then type "flutter new". When it appears, select theFlutter: New Project command.
- SelectEmpty Application. Choose a directory in which to create your project. This should be any directory that doesn't require elevated privileges or have a space in its path. Examples include your home directory or
C:\src\
.
- Name your project
brick_breaker
. The remainder of this codelab presumes you named your appbrick_breaker
.
Flutter now creates your project folder and VS Code opens it. You'll now overwrite the contents of two files with a basic scaffold of the app.
Copy & Paste the initial app
This adds the example code provided in this codelab to your app.
- In the left pane of VS Code, clickExplorer and open the
pubspec.yaml
file.
- Replace the contents of this file with the following:
pubspec.yaml
name:brick_breakerdescription:"Re-implementing Woz's Breakout"publish_to:'none'version:0.1.0environment:sdk:^3.8.0dependencies:flutter:sdk:flutterflame:^1.28.1flutter_animate:^4.5.2google_fonts:^6.2.1dev_dependencies:flutter_test:sdk:flutterflutter_lints:^5.0.0flutter:uses-material-design:true
Thepubspec.yaml
file specifies basic information about your app, such as its current version, its dependencies, and the assets with which it will ship.
Note: If you gave your app a name other thanbrick_breaker
, you need to change the first line correspondingly.
- Open
main.dart
file inlib/
directory.
- Replace the contents of this file with the following:
lib/main.dart
import'package:flame/game.dart';import'package:flutter/material.dart';voidmain(){finalgame=FlameGame();runApp(GameWidget(game:game));}
- Run this code to verify everything is working. It should display a new window with only a blank black background. The world's worst video game is now rendering at 60fps!
4. Create the game
Size up the game
A game played in two dimensions (2D) needs a play area. You will construct an area of specific dimensions, and then use these dimensions to size other aspects of the game.
There are various ways to lay out coordinates in the playing area. By one convention you can measure direction from the center of the screen with the origin(0,0)
at the center of the screen, the positive values move items to the right along the x axis and up along the y axis. This standard applies to most current games these days, especially when games that involve three dimensions.
The convention when the original Breakout game was created was to set the origin in the top left corner. The positive x direction remained the same, however y was flipped. The x positive x direction was right and y was down. To stay true to the era, this game sets the origin to the top left corner.
Aside: It may seem confusing that the way dimensions work in these different eras are different. There are two things to notice here.
First, the defaults for people working in games depended on their default context. For programmers in the 70s and 80s, the default visual environment was a text buffer with(0,0)
at the top left. This convention carried over to their graphical dimension defaults. In the 90s when 3D games became the default, the programmers working on these games were more focused on the mathematics of three dimensions and thus the putting(0,0)
in the center of the screen made more sense.
Second, you can express these games using any of these conventions, and it will work, as long as everything is consistent. It is possible to express Breakout with(0,0)
placed anywhere on the screen and the player of the game won't notice any change. Model the world in a way that makes expressing your problem easier for you.
Create a file calledconfig.dart
in a new directory calledlib/src
. This file will gain more constants in the following steps.
lib/src/config.dart
constgameWidth=820.0;constgameHeight=1600.0;
This game will be 820 pixels wide and 1600 pixels high. The game area scales to fit the window in which it is displayed, but all the components added to the screen conform to this height and width.
Create a PlayArea
In the game of Breakout, the ball bounces off the walls of the play area. To accommodate collisions, you need aPlayArea
component first.
- Create a file called
play_area.dart
in a new directory calledlib/src/components
. - Add the following to this file.
lib/src/components/play_area.dart
import'dart:async';import'package:flame/components.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';classPlayAreaextendsRectangleComponentwithHasGameReference<BrickBreaker>{PlayArea():super(paint:Paint()..color=constColor(0xfff2e8cf));@overrideFutureOr<void>onLoad()async{super.onLoad();size=Vector2(game.width,game.height);}}
Note: On entering the previous code, the IDE displays some angry looking red squiggly lines. You will fix this later when you create theBrickBreaker
class.
Where Flutter hasWidget
s, Flame hasComponent
s. Where Flutter apps consist of creating trees of widgets, Flame games consist of maintaining trees of components.
Therein lies an interesting difference between Flutter and Flame. Flutter's widget tree is an ephemeral description that is built to be used to update the persistent and mutableRenderObject
layer. Flame's components are persistent and mutable, with an expectation that the developer will use these components as part of a simulation system.
Flame's components are optimized for expressing game mechanics. This codelab will start with the game loop, featured in the next step.
- To control clutter, add a file containing all the components in this project. Create a
components.dart
file inlib/src/components
and add the following content.
lib/src/components/components.dart
export'play_area.dart';
Theexport
directive plays the inverse role ofimport
. It declares what functionality this file exposes when imported into another file. This file will grow more entries as you add new components in the following steps.
Create a Flame game
To extinguish the red squiggles from the previous step, derive a new subclass for Flame'sFlameGame
.
- Create a file named
brick_breaker.dart
inlib/src
and add the following code.
lib/src/brick_breaker.dart
import'dart:async';import'package:flame/components.dart';import'package:flame/game.dart';import'components/components.dart';import'config.dart';classBrickBreakerextendsFlameGame{BrickBreaker():super(camera:CameraComponent.withFixedResolution(width:gameWidth,height:gameHeight,),);doublegetwidth=>size.x;doublegetheight=>size.y;@overrideFutureOr<void>onLoad()async{super.onLoad();camera.viewfinder.anchor=Anchor.topLeft;world.add(PlayArea());}}
This file coordinates the game's actions. During construction of the game instance, this code configures the game to use fixed resolution rendering. The game resizes to fill the screen that contains it and addsletterboxing as required.
You expose the width and height of the game so that the children components, likePlayArea
, can set themselves to the appropriate size.
In theonLoad
overridden method, your code performs two actions.
- Configures the top left as the anchor for the viewfinder. By default, the
viewfinder
uses the middle of the area as the anchor for(0,0)
. - Adds the
PlayArea
to theworld
. The world represents the game world. It projects all of its children through theCameraComponent
s view transformation.
Warning: You can addComponent
s as children of theFlameGame
directly. If you do, instead of adding them as children of theworld
component, thenCameraComponent
won't transform those Components. This may confuse you and your app's players when resizing the window doesn't behave how you expect.
Get the game on screen
To see all the changes you have made in this step, update yourlib/main.dart
file with the following changes.
lib/main.dart
import'package:flame/game.dart';import'package:flutter/material.dart';import'src/brick_breaker.dart';//Addthisimportvoidmain(){finalgame=BrickBreaker();//ModifythislinerunApp(GameWidget(game:game));}
After you make these changes, restart the game. The game should resemble the following figure.
Heads up: Flame doesn't currently support Flutter'sHot Reload capability. You need to make full restarts to see the changes you have made.
In the next step, you will add a ball to the world, and get it moving!
5. Display the ball
Create the ball component
Putting a moving ball on the screen involves creating another component and adding it to the game world.
- Edit the contents of
lib/src/config.dart
file as follows.
lib/src/config.dart
constgameWidth=820.0;constgameHeight=1600.0;constballRadius=gameWidth*0.02;//Addthisconstant
The design pattern of defining named constants as derived values will return many times in this codelab. This lets you modify the top levelgameWidth
andgameHeight
to explore how the game look and feel changes as result.
- Create the
Ball
component in a file calledball.dart
inlib/src/components
.
lib/src/components/ball.dart
import'package:flame/components.dart';import'package:flutter/material.dart';classBallextendsCircleComponent{Ball({requiredthis.velocity,requiredsuper.position,requireddoubleradius,}):super(radius:radius,anchor:Anchor.center,paint:Paint()..color=constColor(0xff1e6091)..style=PaintingStyle.fill,);finalVector2velocity;@overridevoidupdate(doubledt){super.update(dt);position+=velocity*dt;}}
Earlier, you defined thePlayArea
using theRectangleComponent
, so it stands to reason that more shapes exist.CircleComponent
, likeRectangleComponent
, derives fromPositionedComponent
, so you can position the ball on the screen. More importantly, its position can be updated.
This component introduces the concept ofvelocity
, or change in position over time. Velocity is aVector2
object asvelocity is both speed and direction. To update position, override theupdate
method, which the game engine calls for every frame. Thedt
is the duration between the previous frame and this frame. This lets you adapt to factors like different frame rates (60hz or 120hz) or long frames due to excessive computation.
Aside: In this game, you are usingVectors as a way of modeling attributes like location and velocity that have components. As this game simulates a 2 dimensional world, location and velocity have two components,x
andy
.
Wrapping these components up as a single concept enables simpler handling of concepts like scaling a vector, for example speeding up a ball by multiplying its velocity by a number greater than one, or updating a position with an appropriate portion of its current velocity.
See theIntro to Vectors lesson on Khan Academy for more in depth coverage of using vectors for crafting simulations.
Pay close attention to theposition += velocity * dt
update. This is how you implement updating a discrete simulation of motion over time.
- To include the
Ball
component in the list of components, edit thelib/src/components/components.dart
file as follows.
lib/src/components/components.dart
export'ball.dart';//Addthisexportexport'play_area.dart';
Adding the ball to the world
You have a ball. Place it in the world and set it up to move around the play area.
Edit thelib/src/brick_breaker.dart
file as follows.
lib/src/brick_breaker.dart
import'dart:async';import'dart:math'asmath;//Addthisimportimport'package:flame/components.dart';import'package:flame/game.dart';import'components/components.dart';import'config.dart';classBrickBreakerextendsFlameGame{BrickBreaker():super(camera:CameraComponent.withFixedResolution(width:gameWidth,height:gameHeight,),);finalrand=math.Random();//Addthisvariabledoublegetwidth=>size.x;doublegetheight=>size.y;@overrideFutureOr<void>onLoad()async{super.onLoad();camera.viewfinder.anchor=Anchor.topLeft;world.add(PlayArea());world.add(Ball(//Addfromhere...radius:ballRadius,position:size/2,velocity:Vector2((rand.nextDouble()-0.5)*width,height*0.2,).normalized()..scale(height/4),),);debugMode=true;//Tohere.}}
This change adds theBall
component to theworld
. To set the ball'sposition
to the center of the display area, the code first halves the size of the game, asVector2
has operator overloads (*
and/
) to scale aVector2
by a scalar value.
To set the ball'svelocity
involves more complexity. The intent is to move the ball down the screen in a random direction at a reasonable speed. The call to thenormalized
method creates aVector2
object set to the same direction as the originalVector2
, but scaled down to a distance of 1. This keeps the speed of the ball consistent no matter which direction the ball goes. The ball's velocity is then scaled up to be a 1/4 of the height of the game.
Getting these various values right involves some iteration, also known as playtesting in the industry.
The last line turns on the debugging display, which adds additional information to the display to help with debugging.
Aside: Vectors usually represent both direction and size. The size might be position, or in the case of velocity, speed. The direction is a more general concept, and there is frequently a need to extract the direction of a vector for the comparison of two vectors.
Thenormal is the direction of a vector, created by converting a position or velocity vector to a new vector with the same direction, but with a size of1
. In the above code, normalization is used to take a vector or random direction and give it a consistent speed.
When you now run the game, it should resemble the following display.
Both thePlayArea
component and theBall
component both have debugging information, but the background mattes crop thePlayArea
's numbers. The reason everything has debugging information displayed is because you turned ondebugMode
for the entire component tree. You could also turn on debugging for only selected components, if that is more useful.
If you restart your game a few times, you might notice that the ball doesn't interact with the walls quite as expected. To accomplish that effect, you need to add collision detection, which you will do in the next step.
6. Bounce around
Add collision detection
Collision detection adds a behavior where your game recognizes when two objects came into contact with each other.
To add collision detection to the game, add theHasCollisionDetection
mixin to theBrickBreaker
game as shown in the following code.
lib/src/brick_breaker.dart
import'dart:async';import'dart:math'asmath;import'package:flame/components.dart';import'package:flame/game.dart';import'components/components.dart';import'config.dart';classBrickBreakerextendsFlameGamewithHasCollisionDetection{//ModifythislineBrickBreaker():super(camera:CameraComponent.withFixedResolution(width:gameWidth,height:gameHeight,),);finalrand=math.Random();doublegetwidth=>size.x;doublegetheight=>size.y;@overrideFutureOr<void>onLoad()async{super.onLoad();camera.viewfinder.anchor=Anchor.topLeft;world.add(PlayArea());world.add(Ball(radius:ballRadius,position:size/2,velocity:Vector2((rand.nextDouble()-0.5)*width,height*0.2,).normalized()..scale(height/4),),);debugMode=true;}}
This tracks the hitboxes of components and triggers collision callbacks on every game tick.
Aside: Ahitbox is the area that the collision detection system will use to calculate if a component has collided with another component. The reason for the name is that the area used for collision detection is frequently simplified from the visual representation the player sees on screen, frequently to a rectangle or box. The size of the hitbox may differ from the on screen avatar to make the player's game play easier.
In this game, the size of the hitbox for the ball and the bricks is the same as the component they are attached to. The bat, however, has a rectangular hitbox without the rounded ends that the bat has on screen. This effectively makes the bat slightly larger than it appears.
To start populating the game's hitboxes, modify thePlayArea
component as shown below.
lib/src/components/play_area.dart
import'dart:async';import'package:flame/collisions.dart';//Addthisimportimport'package:flame/components.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';classPlayAreaextendsRectangleComponentwithHasGameReference<BrickBreaker>{PlayArea():super(paint:Paint()..color=constColor(0xfff2e8cf),children:[RectangleHitbox()],//Addthisparameter);@overrideFutureOr<void>onLoad()async{super.onLoad();size=Vector2(game.width,game.height);}}
Adding aRectangleHitbox
component as a child of theRectangleComponent
will construct a hit box for collision detection that matches the size of the parent component. There is a factory constructor forRectangleHitbox
calledrelative
for times when you want a hitbox that is smaller, or larger, than the parent component.
Bounce the ball
So far, adding collision detection has made no difference to the gameplay. It does change once you modify theBall
component. It's the ball's behavior that has to change when it collides with thePlayArea
.
Modify theBall
component as follows.
lib/src/components/ball.dart
import'package:flame/collisions.dart';//Addthisimportimport'package:flame/components.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';//Andthisimportimport'play_area.dart';//AndthisonetooclassBallextendsCircleComponentwithCollisionCallbacks,HasGameReference<BrickBreaker>{//AddthesemixinsBall({requiredthis.velocity,requiredsuper.position,requireddoubleradius,}):super(radius:radius,anchor:Anchor.center,paint:Paint()..color=constColor(0xff1e6091)..style=PaintingStyle.fill,children:[CircleHitbox()],//Addthisparameter);finalVector2velocity;@overridevoidupdate(doubledt){super.update(dt);position+=velocity*dt;}@override//Addfromhere...voidonCollisionStart(Set<Vector2>intersectionPoints,PositionComponentother,){super.onCollisionStart(intersectionPoints,other);if(otherisPlayArea){if(intersectionPoints.first.y <=0){velocity.y=-velocity.y;}elseif(intersectionPoints.first.x <=0){velocity.x=-velocity.x;}elseif(intersectionPoints.first.x >=game.width){velocity.x=-velocity.x;}elseif(intersectionPoints.first.y >=game.height){removeFromParent();}}else{debugPrint('collision with $other');}}//Tohere.}
This example makes a major change with the addition of theonCollisionStart
callback. The collision detection system added toBrickBreaker
in the prior example calls this callback.
First, the code tests if theBall
collided withPlayArea
. This seems redundant for now, as there are no other components in the game world. That will change in the next step, when you add a bat to the world. Then, it also adds anelse
condition to handle when the ball collides with things that aren't the bat. A gentle reminder to implement remaining logic, if you will.
When the ball collides with the bottom wall, it just disappears from the playing surface while still very much in view. You handle this artifact in a future step, using the power of Flame's Effects.
Note: Flame's collision callbacks have a lifecycle. The callbacks areonCollisionStart
,onCollision
, andonCollisionEnd
. The initial version of this game was written in terms ofonCollision
and required additional code to handle follow-on collision callbacks on later game ticks. Using the right callback for the right purposesignificantly simplifies your code!
Now that you have the ball colliding with the walls of the game, it sure would be useful to give the player a bat to hit the ball with...
7. Get bat on ball
Create the bat
To add a bat to keep the ball in play within the game,
- Insert some constants in
lib/src/config.dart
file as follows.
lib/src/config.dart
constgameWidth=820.0;constgameHeight=1600.0;constballRadius=gameWidth*0.02;constbatWidth=gameWidth*0.2;//Addfromhere...constbatHeight=ballRadius*2;constbatStep=gameWidth*0.05;//Tohere.
ThebatHeight
andbatWidth
constants are self explanatory. ThebatStep
constant, on the other hand, needs a touch of explanation. To interact with the ball in this game, the player can drag the bat with the mouse or finger, depending on the platform, or use the keyboard. ThebatStep
constant configures how far the bat steps for each left or right arrow key press.
- Define the
Bat
component class as follows.
lib/src/components/bat.dart
import'package:flame/collisions.dart';import'package:flame/components.dart';import'package:flame/effects.dart';import'package:flame/events.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';classBatextendsPositionComponentwithDragCallbacks,HasGameReference<BrickBreaker>{Bat({requiredthis.cornerRadius,requiredsuper.position,requiredsuper.size,}):super(anchor:Anchor.center,children:[RectangleHitbox()]);finalRadiuscornerRadius;final_paint=Paint()..color=constColor(0xff1e6091)..style=PaintingStyle.fill;@overridevoidrender(Canvascanvas){super.render(canvas);canvas.drawRRect(RRect.fromRectAndRadius(Offset.zero &size.toSize(),cornerRadius),_paint,);}@overridevoidonDragUpdate(DragUpdateEventevent){super.onDragUpdate(event);position.x=(position.x+event.localDelta.x).clamp(0,game.width);}voidmoveBy(doubledx){add(MoveToEffect(Vector2((position.x+dx).clamp(0,game.width),position.y),EffectController(duration:0.1),),);}}
This component introduces a few new capabilities.
First, the Bat component is aPositionComponent
, not aRectangleComponent
nor aCircleComponent
. This means this code needs to render theBat
on screen. To accomplish this, it overrides therender
callback.
Looking closely at thecanvas.drawRRect
(draw rounded rectangle) call, and you might ask yourself, "where is the rectangle?" TheOffset.zero & size.toSize()
leverages anoperator &
overload on thedart:ui
Offset
class that createsRect
s. This shorthand might confuse you at first, but you will see it frequently in lower level Flutter and Flame code.
Second, thisBat
component is draggable using either finger or mouse depending on platform. To implement this functionality, you add theDragCallbacks
mixin and override theonDragUpdate
event.
Last, theBat
component needs to respond to keyboard control. ThemoveBy
function allows other code to tell this bat to move left or right by a certain number of virtual pixels. This function introduces a new capability of the Flame game engine:Effect
s. By adding theMoveToEffect
object as a child of this component, the player sees the bat animated to a new position. There are a collection ofEffect
s available in Flame to perform a variety of effects.
The Effect's constructor arguments include a reference to thegame
getter. This is why you include theHasGameReference
mixin on this class. This mixin adds a type-safegame
accessor to this component to access theBrickBreaker
instance at the top of the component tree.
You may be wondering why theclamp
on theBat
'sx
position allows for the bat to go halfway off screen. In a previous version of this codelab there was code here to prevent the bat from going off screen, but this reduced the ability of the player to use the bat's effectively curved front edge to its fullest. This code has been updated to allow for the bat to be slid halfway off screen, to allow for more interesting and fun game play.
- To make the
Bat
available toBrickBreaker
, update thelib/src/components/components.dart
file as follows.
lib/src/components/components.dart
export'ball.dart';export'bat.dart';//Addthisexportexport'play_area.dart';
Add the bat to the world
To add theBat
component to the game world, updateBrickBreaker
as follows.
lib/src/brick_breaker.dart
import'dart:async';import'dart:math'asmath;import'package:flame/components.dart';import'package:flame/events.dart';//Addthisimportimport'package:flame/game.dart';import'package:flutter/material.dart';//Andthisimportimport'package:flutter/services.dart';//Andthisimport'components/components.dart';import'config.dart';classBrickBreakerextendsFlameGamewithHasCollisionDetection,KeyboardEvents{//ModifythislineBrickBreaker():super(camera:CameraComponent.withFixedResolution(width:gameWidth,height:gameHeight,),);finalrand=math.Random();doublegetwidth=>size.x;doublegetheight=>size.y;@overrideFutureOr<void>onLoad()async{super.onLoad();camera.viewfinder.anchor=Anchor.topLeft;world.add(PlayArea());world.add(Ball(radius:ballRadius,position:size/2,velocity:Vector2((rand.nextDouble()-0.5)*width,height*0.2,).normalized()..scale(height/4),),);world.add(//Addfromhere...Bat(size:Vector2(batWidth,batHeight),cornerRadius:constRadius.circular(ballRadius/2),position:Vector2(width/2,height*0.95),),);//Tohere.debugMode=true;}@override//Addfromhere...KeyEventResultonKeyEvent(KeyEventevent,Set<LogicalKeyboardKey>keysPressed,){super.onKeyEvent(event,keysPressed);switch(event.logicalKey){caseLogicalKeyboardKey.arrowLeft:world.children.query<Bat>().first.moveBy(-batStep);caseLogicalKeyboardKey.arrowRight:world.children.query<Bat>().first.moveBy(batStep);}returnKeyEventResult.handled;}//Tohere.}
The addition of theKeyboardEvents
mixin and the overriddenonKeyEvent
method handle the keyboard input. Recall the code you added earlier to move the bat by the appropriate step amount.
The remaining chunk of added code adds the bat to the game world in the appropriate position and with the right proportions. Having all these settings exposed in this file simplifies your ability to tweak the relative size of the bat and the ball to get the right feel for the game.
If you play the game at this point, you see that you can move the bat to intercept the ball, but get no visible response, apart from the debug logging that you left inBall
's collision detection code.
Time to fix that now. Edit theBall
component as follows.
lib/src/components/ball.dart
import'package:flame/collisions.dart';import'package:flame/components.dart';import'package:flame/effects.dart';//Addthisimportimport'package:flutter/material.dart';import'../brick_breaker.dart';import'bat.dart';//Andthisimportimport'play_area.dart';classBallextendsCircleComponentwithCollisionCallbacks,HasGameReference<BrickBreaker>{Ball({requiredthis.velocity,requiredsuper.position,requireddoubleradius,}):super(radius:radius,anchor:Anchor.center,paint:Paint()..color=constColor(0xff1e6091)..style=PaintingStyle.fill,children:[CircleHitbox()],);finalVector2velocity;@overridevoidupdate(doubledt){super.update(dt);position+=velocity*dt;}@overridevoidonCollisionStart(Set<Vector2>intersectionPoints,PositionComponentother,){super.onCollisionStart(intersectionPoints,other);if(otherisPlayArea){if(intersectionPoints.first.y <=0){velocity.y=-velocity.y;}elseif(intersectionPoints.first.x <=0){velocity.x=-velocity.x;}elseif(intersectionPoints.first.x >=game.width){velocity.x=-velocity.x;}elseif(intersectionPoints.first.y >=game.height){add(RemoveEffect(delay:0.35));//Modifyfromhere...}}elseif(otherisBat){velocity.y=-velocity.y;velocity.x=velocity.x+(position.x-other.position.x)/other.size.x*game.width*0.3;}else{//Tohere.debugPrint('collision with $other');}}}
These code changes fix two separate issues.
First, it fixes the ball popping out of existence the moment it touches the bottom of the screen. To fix this issue, you replace theremoveFromParent
call withRemoveEffect
. TheRemoveEffect
removes the ball from the game world after letting the ball exit the viewable play area.
Second, these changes fix the handling of collision between bat and ball. This handling code works very much in the player's favor. As long as the player touches the ball with the bat, the ball returns to the top of the screen. If this feels too forgiving and you want something more realistic, then change this handling to better fit how you want your game to feel.
It's worth pointing out the complexity of thevelocity
update. It doesn't just reversethey
component of the velocity, as was done for the wall collisions. It also updates thex
component in a way that depends on the relative position of the bat and ballat the time of contact. This gives the player more control over what the ball does, but exactly how is not communicated to the player in any way except through play.
Now that you have a bat with which to hit the ball, it'd be neat to have some bricks to break with the ball!
8. Break down the wall
Creating the bricks
To add bricks to the game,
- Insert some constants in
lib/src/config.dart
file as follows.
lib/src/config.dart
import'package:flutter/material.dart';//AddthisimportconstbrickColors=[//AddthisconstColor(0xfff94144),Color(0xfff3722c),Color(0xfff8961e),Color(0xfff9844a),Color(0xfff9c74f),Color(0xff90be6d),Color(0xff43aa8b),Color(0xff4d908e),Color(0xff277da1),Color(0xff577590),];constgameWidth=820.0;constgameHeight=1600.0;constballRadius=gameWidth*0.02;constbatWidth=gameWidth*0.2;constbatHeight=ballRadius*2;constbatStep=gameWidth*0.05;constbrickGutter=gameWidth*0.015;//Addfromhere...finalbrickWidth=(gameWidth-(brickGutter*(brickColors.length+1)))/brickColors.length;constbrickHeight=gameHeight*0.03;constdifficultyModifier=1.03;//Tohere.
- Insert the
Brick
component as follows.
lib/src/components/brick.dart
import'package:flame/collisions.dart';import'package:flame/components.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';import'../config.dart';import'ball.dart';import'bat.dart';classBrickextendsRectangleComponentwithCollisionCallbacks,HasGameReference<BrickBreaker>{Brick({requiredsuper.position,requiredColorcolor}):super(size:Vector2(brickWidth,brickHeight),anchor:Anchor.center,paint:Paint()..color=color..style=PaintingStyle.fill,children:[RectangleHitbox()],);@overridevoidonCollisionStart(Set<Vector2>intersectionPoints,PositionComponentother,){super.onCollisionStart(intersectionPoints,other);removeFromParent();if(game.world.children.query<Brick>().length==1){game.world.removeAll(game.world.children.query<Ball>());game.world.removeAll(game.world.children.query<Bat>());}}}
By now, most of this code should be familiar. This code uses aRectangleComponent
, with both collision detection and a type-safe reference to theBrickBreaker
game at the top of the component tree.
The most important new concept this code introduces is how the player achieves the win condition. The win condition check queries the world for bricks, and confirms that only one remains. This might be a bit confusing, because the preceding line removes this brick from its parent.
The key point to understand is that component removal is a queued command. It removes the brick after this code runs, but before the next tick of the game world.
To make theBrick
component accessible toBrickBreaker
, editlib/src/components/components.dart
as follows.
lib/src/components/components.dart
export'ball.dart';export'bat.dart';export'brick.dart';//Addthisexportexport'play_area.dart';
Add bricks to the world
Update theBall
component as follows.
lib/src/components/ball.dart
import'package:flame/collisions.dart';import'package:flame/components.dart';import'package:flame/effects.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';import'bat.dart';import'brick.dart';//Addthisimportimport'play_area.dart';classBallextendsCircleComponentwithCollisionCallbacks,HasGameReference<BrickBreaker>{Ball({requiredthis.velocity,requiredsuper.position,requireddoubleradius,requiredthis.difficultyModifier,//Addthisparameter}):super(radius:radius,anchor:Anchor.center,paint:Paint()..color=constColor(0xff1e6091)..style=PaintingStyle.fill,children:[CircleHitbox()],);finalVector2velocity;finaldoubledifficultyModifier;//Addthismember@overridevoidupdate(doubledt){super.update(dt);position+=velocity*dt;}@overridevoidonCollisionStart(Set<Vector2>intersectionPoints,PositionComponentother,){super.onCollisionStart(intersectionPoints,other);if(otherisPlayArea){if(intersectionPoints.first.y <=0){velocity.y=-velocity.y;}elseif(intersectionPoints.first.x <=0){velocity.x=-velocity.x;}elseif(intersectionPoints.first.x >=game.width){velocity.x=-velocity.x;}elseif(intersectionPoints.first.y >=game.height){add(RemoveEffect(delay:0.35));}}elseif(otherisBat){velocity.y=-velocity.y;velocity.x=velocity.x+(position.x-other.position.x)/other.size.x*game.width*0.3;}elseif(otherisBrick){//Modifyfromhere...if(position.y <other.position.y-other.size.y/2){velocity.y=-velocity.y;}elseif(position.y >other.position.y+other.size.y/2){velocity.y=-velocity.y;}elseif(position.x <other.position.x){velocity.x=-velocity.x;}elseif(position.x >other.position.x){velocity.x=-velocity.x;}velocity.setFrom(velocity*difficultyModifier);//Tohere.}}}
This introduces the only new aspect, a difficulty modifier that increases the ball velocity after each brick collision. This tunable parameter needs to be playtested to find the appropriate difficulty curve that is appropriate for your game.
Edit theBrickBreaker
game as follows.
lib/src/brick_breaker.dart
import'dart:async';import'dart:math'asmath;import'package:flame/components.dart';import'package:flame/events.dart';import'package:flame/game.dart';import'package:flutter/material.dart';import'package:flutter/services.dart';import'components/components.dart';import'config.dart';classBrickBreakerextendsFlameGamewithHasCollisionDetection,KeyboardEvents{BrickBreaker():super(camera:CameraComponent.withFixedResolution(width:gameWidth,height:gameHeight,),);finalrand=math.Random();doublegetwidth=>size.x;doublegetheight=>size.y;@overrideFutureOr<void>onLoad()async{super.onLoad();camera.viewfinder.anchor=Anchor.topLeft;world.add(PlayArea());world.add(Ball(difficultyModifier:difficultyModifier,//Addthisargumentradius:ballRadius,position:size/2,velocity:Vector2((rand.nextDouble()-0.5)*width,height*0.2,).normalized()..scale(height/4),),);world.add(Bat(size:Vector2(batWidth,batHeight),cornerRadius:constRadius.circular(ballRadius/2),position:Vector2(width/2,height*0.95),),);awaitworld.addAll([//Addfromhere...for(vari=0;i <brickColors.length;i++)for(varj=1;j <=5;j++)Brick(position:Vector2((i+0.5)*brickWidth+(i+1)*brickGutter,(j+2.0)*brickHeight+j*brickGutter,),color:brickColors[i],),]);//Tohere.debugMode=true;}@overrideKeyEventResultonKeyEvent(KeyEventevent,Set<LogicalKeyboardKey>keysPressed,){super.onKeyEvent(event,keysPressed);switch(event.logicalKey){caseLogicalKeyboardKey.arrowLeft:world.children.query<Bat>().first.moveBy(-batStep);caseLogicalKeyboardKey.arrowRight:world.children.query<Bat>().first.moveBy(batStep);}returnKeyEventResult.handled;}}
If you run the game as it currently stands, it displays all the key game mechanics. You could turn off debugging and call it done, but something feels missing.
How about a welcome screen, a game over screen, and maybe a score? Flutter can add these features to the game, and that is where you will turn your attention next.
9. Win the game
Add play states
In this step, you embed the Flame game inside of a Flutter wrapper, and then add Flutter overlays for the welcome, game over, and won screens.
First, you modify the game and component files to implement a play state that reflects whether to show an overlay, and if so, which one.
- Modify the
BrickBreaker
game as follows.
lib/src/brick_breaker.dart
import'dart:async';import'dart:math'asmath;import'package:flame/components.dart';import'package:flame/events.dart';import'package:flame/game.dart';import'package:flutter/material.dart';import'package:flutter/services.dart';import'components/components.dart';import'config.dart';enumPlayState{welcome,playing,gameOver,won}//AddthisenumerationclassBrickBreakerextendsFlameGamewithHasCollisionDetection,KeyboardEvents,TapDetector{//ModifythislineBrickBreaker():super(camera:CameraComponent.withFixedResolution(width:gameWidth,height:gameHeight,),);finalrand=math.Random();doublegetwidth=>size.x;doublegetheight=>size.y;latePlayState_playState;//Addfromhere...PlayStategetplayState=>_playState;setplayState(PlayStateplayState){_playState=playState;switch(playState){casePlayState.welcome:casePlayState.gameOver:casePlayState.won:overlays.add(playState.name);casePlayState.playing:overlays.remove(PlayState.welcome.name);overlays.remove(PlayState.gameOver.name);overlays.remove(PlayState.won.name);}}//Tohere.@overrideFutureOr<void>onLoad()async{super.onLoad();camera.viewfinder.anchor=Anchor.topLeft;world.add(PlayArea());playState=PlayState.welcome;//Addfromhere...}voidstartGame(){if(playState==PlayState.playing)return;world.removeAll(world.children.query<Ball>());world.removeAll(world.children.query<Bat>());world.removeAll(world.children.query<Brick>());playState=PlayState.playing;//Tohere.world.add(Ball(difficultyModifier:difficultyModifier,radius:ballRadius,position:size/2,velocity:Vector2((rand.nextDouble()-0.5)*width,height*0.2,).normalized()..scale(height/4),),);world.add(Bat(size:Vector2(batWidth,batHeight),cornerRadius:constRadius.circular(ballRadius/2),position:Vector2(width/2,height*0.95),),);world.addAll([//Droptheawaitfor(vari=0;i <brickColors.length;i++)for(varj=1;j <=5;j++)Brick(position:Vector2((i+0.5)*brickWidth+(i+1)*brickGutter,(j+2.0)*brickHeight+j*brickGutter,),color:brickColors[i],),]);}//DropthedebugMode@override//Addfromhere...voidonTap(){super.onTap();startGame();}//Tohere.@overrideKeyEventResultonKeyEvent(KeyEventevent,Set<LogicalKeyboardKey>keysPressed,){super.onKeyEvent(event,keysPressed);switch(event.logicalKey){caseLogicalKeyboardKey.arrowLeft:world.children.query<Bat>().first.moveBy(-batStep);caseLogicalKeyboardKey.arrowRight:world.children.query<Bat>().first.moveBy(batStep);caseLogicalKeyboardKey.space://Addfromhere...caseLogicalKeyboardKey.enter:startGame();//Tohere.}returnKeyEventResult.handled;}@overrideColorbackgroundColor()=>constColor(0xfff2e8cf);//Addthisoverride}
This code changes a good deal of theBrickBreaker
game. Adding theplayState
enumeration takes a lot of work. This captures where the player is in entering, playing, and either losing or winning the game. At the top of the file, you define the enumeration, then instantiate it as a hidden state with matching getters and setters. These getters and setters enable modifying overlays when the various parts of the game trigger play state transitions.
Next, you split the code inonLoad
into onLoad and a newstartGame
method. Before this change, you could only start a new game through restarting the game. With these new additions, the player can now start a new game without such drastic measures.
To permit the player to start a new game, you configured two new handlers for the game. You added a tap handler and extended the keyboard handler to enable the user to start a new game in multiple modalities. With play state modeled, it would make sense to update the components to trigger play state transitions when the player either wins, or loses.
- Modify the
Ball
component as follows.
lib/src/components/ball.dart
import'package:flame/collisions.dart';import'package:flame/components.dart';import'package:flame/effects.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';import'bat.dart';import'brick.dart';import'play_area.dart';classBallextendsCircleComponentwithCollisionCallbacks,HasGameReference<BrickBreaker>{Ball({requiredthis.velocity,requiredsuper.position,requireddoubleradius,requiredthis.difficultyModifier,}):super(radius:radius,anchor:Anchor.center,paint:Paint()..color=constColor(0xff1e6091)..style=PaintingStyle.fill,children:[CircleHitbox()],);finalVector2velocity;finaldoubledifficultyModifier;@overridevoidupdate(doubledt){super.update(dt);position+=velocity*dt;}@overridevoidonCollisionStart(Set<Vector2>intersectionPoints,PositionComponentother,){super.onCollisionStart(intersectionPoints,other);if(otherisPlayArea){if(intersectionPoints.first.y <=0){velocity.y=-velocity.y;}elseif(intersectionPoints.first.x <=0){velocity.x=-velocity.x;}elseif(intersectionPoints.first.x >=game.width){velocity.x=-velocity.x;}elseif(intersectionPoints.first.y >=game.height){add(RemoveEffect(delay:0.35,onComplete:(){//Modifyfromheregame.playState=PlayState.gameOver;},),);//Tohere.}}elseif(otherisBat){velocity.y=-velocity.y;velocity.x=velocity.x+(position.x-other.position.x)/other.size.x*game.width*0.3;}elseif(otherisBrick){if(position.y <other.position.y-other.size.y/2){velocity.y=-velocity.y;}elseif(position.y >other.position.y+other.size.y/2){velocity.y=-velocity.y;}elseif(position.x <other.position.x){velocity.x=-velocity.x;}elseif(position.x >other.position.x){velocity.x=-velocity.x;}velocity.setFrom(velocity*difficultyModifier);}}}
This small change adds anonComplete
callback to theRemoveEffect
which triggers thegameOver
play state. This should feel about right if the player allows the ball to escape off the bottom of the screen.
- Edit the
Brick
component as follows.
lib/src/components/brick.dart
impimport'package:flame/collisions.dart';import'package:flame/components.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';import'../config.dart';import'ball.dart';import'bat.dart';classBrickextendsRectangleComponentwithCollisionCallbacks,HasGameReference<BrickBreaker>{Brick({requiredsuper.position,requiredColorcolor}):super(size:Vector2(brickWidth,brickHeight),anchor:Anchor.center,paint:Paint()..color=color..style=PaintingStyle.fill,children:[RectangleHitbox()],);@overridevoidonCollisionStart(Set<Vector2>intersectionPoints,PositionComponentother,){super.onCollisionStart(intersectionPoints,other);removeFromParent();if(game.world.children.query<Brick>().length==1){game.playState=PlayState.won;//Addthislinegame.world.removeAll(game.world.children.query<Ball>());game.world.removeAll(game.world.children.query<Bat>());}}}
On the other hand, if the player can break all the bricks, they have earned a "game won" screen. Well done player, well done!
Add the Flutter wrapper
To provide somewhere to embed the game and add play state overlays, add the Flutter shell.
- Create a
widgets
directory underlib/src
. - Add a
game_app.dart
file and insert the following content to that file.
lib/src/widgets/game_app.dart
import'package:flame/game.dart';import'package:flutter/material.dart';import'package:google_fonts/google_fonts.dart';import'../brick_breaker.dart';import'../config.dart';classGameAppextendsStatelessWidget{constGameApp({super.key});@overrideWidgetbuild(BuildContextcontext){returnMaterialApp(debugShowCheckedModeBanner:false,theme:ThemeData(textTheme:GoogleFonts.pressStart2pTextTheme().apply(bodyColor:constColor(0xff184e77),displayColor:constColor(0xff184e77),),),home:Scaffold(body:Container(decoration:constBoxDecoration(gradient:LinearGradient(begin:Alignment.topCenter,end:Alignment.bottomCenter,colors:[Color(0xffa9d6e5),Color(0xfff2e8cf)],),),child:SafeArea(child:Padding(padding:constEdgeInsets.all(16),child:Center(child:FittedBox(child:SizedBox(width:gameWidth,height:gameHeight,child:GameWidget.controlled(gameFactory:BrickBreaker.new,overlayBuilderMap:{PlayState.welcome.name:(context,game)=>Center(child:Text('TAP TO PLAY',style:Theme.of(context).textTheme.headlineLarge,),),PlayState.gameOver.name:(context,game)=>Center(child:Text('G A M E O V E R',style:Theme.of(context).textTheme.headlineLarge,),),PlayState.won.name:(context,game)=>Center(child:Text('Y O U W O N ! ! !',style:Theme.of(context).textTheme.headlineLarge,),),},),),),),),),),),);}}
Most content in this file follows a standard Flutter widget tree build. The parts specific to Flame include usingGameWidget.controlled
to construct and manage theBrickBreaker
game instance and the newoverlayBuilderMap
argument to theGameWidget
.
ThisoverlayBuilderMap
's keys must align with the overlays that theplayState
setter inBrickBreaker
added or removed. Attempting to set an overlay that is not in this map leads to unhappy faces all around.
- To get this new functionality on screen, replace
lib/main.dart
file with the following content.
lib/main.dart
import'package:flutter/material.dart';import'src/widgets/game_app.dart';voidmain(){runApp(constGameApp());}
If you run this code on iOS, Linux, Windows or the web, the intended output displays in the game. If you target macOS or Android, you need one last tweak to enablegoogle_fonts
to display.
Enabling font access
Add internet permission for Android
For Android, you must add Internet permission. Edit yourAndroidManifest.xml
as follows.
android/app/src/main/AndroidManifest.xml
<manifestxmlns:android="http://schemas.android.com/apk/res/android"><!--Addthefollowingline--><uses-permissionandroid:name="android.permission.INTERNET"/><applicationandroid:label="brick_breaker"android:name="${applicationName}"android:icon="@mipmap/ic_launcher"><activityandroid:name=".MainActivity"android:exported="true"android:launchMode="singleTop"android:taskAffinity=""android:theme="@style/LaunchTheme"android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"android:hardwareAccelerated="true"android:windowSoftInputMode="adjustResize"><!--SpecifiesanAndroidthemetoapplytothisActivityassoonastheAndroidprocesshasstarted.ThisthemeisvisibletotheuserwhiletheFlutterUIinitializes.Afterthat,thisthemecontinuestodeterminetheWindowbackgroundbehindtheFlutterUI.--><meta-dataandroid:name="io.flutter.embedding.android.NormalTheme"android:resource="@style/NormalTheme"/><intent-filter><actionandroid:name="android.intent.action.MAIN"/><categoryandroid:name="android.intent.category.LAUNCHER"/></intent-filter></activity><!--Don't delete the meta-data below.ThisisusedbytheFluttertooltogenerateGeneratedPluginRegistrant.java--><meta-dataandroid:name="flutterEmbedding"android:value="2"/></application><!--Requiredtoqueryactivitiesthatcanprocesstext,see:https://developer.android.com/training/package-visibilityandhttps://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.Inparticular,thisisusedbytheFlutterengineinio.flutter.plugin.text.ProcessTextPlugin.--><queries><intent><actionandroid:name="android.intent.action.PROCESS_TEXT"/><dataandroid:mimeType="text/plain"/></intent></queries></manifest>
Edit entitlement files for macOS
For macOS, you have two files to edit.
- Edit the
DebugProfile.entitlements
file to match the following code.
macos/Runner/DebugProfile.entitlements
<?xmlversion="1.0"encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plistversion="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.cs.allow-jit</key><true/><key>com.apple.security.network.server</key><true/><!--Addfromhere... --><key>com.apple.security.network.client</key><true/><!--tohere.--></dict></plist>
- Edit the
Release.entitlements
file to match the following code
macos/Runner/Release.entitlements
<?xmlversion="1.0"encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plistversion="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><!--Addfromhere... --><key>com.apple.security.network.client</key><true/><!--tohere.--></dict></plist>
Running this as is should display a welcome screen and a game over or won screen on all platforms. Those screens might be a little simplistic and it would be nice to have a score. So, guess what you'll be doing in the next step!
10. Keep score
Add score to the game
In this step, you expose the game score to the surrounding Flutter context. In this step you expose state from the Flame game to the surrounding Flutter state management. This enables the game code to update the score every time the player breaks a brick.
- Modify the
BrickBreaker
game as follows.
lib/src/brick_breaker.dart
import'dart:async';import'dart:math'asmath;import'package:flame/components.dart';import'package:flame/events.dart';import'package:flame/game.dart';import'package:flutter/material.dart';import'package:flutter/services.dart';import'components/components.dart';import'config.dart';enumPlayState{welcome,playing,gameOver,won}classBrickBreakerextendsFlameGamewithHasCollisionDetection,KeyboardEvents,TapDetector{BrickBreaker():super(camera:CameraComponent.withFixedResolution(width:gameWidth,height:gameHeight,),);finalValueNotifier<int>score=ValueNotifier(0);//Addthislinefinalrand=math.Random();doublegetwidth=>size.x;doublegetheight=>size.y;latePlayState_playState;PlayStategetplayState=>_playState;setplayState(PlayStateplayState){_playState=playState;switch(playState){casePlayState.welcome:casePlayState.gameOver:casePlayState.won:overlays.add(playState.name);casePlayState.playing:overlays.remove(PlayState.welcome.name);overlays.remove(PlayState.gameOver.name);overlays.remove(PlayState.won.name);}}@overrideFutureOr<void>onLoad()async{super.onLoad();camera.viewfinder.anchor=Anchor.topLeft;world.add(PlayArea());playState=PlayState.welcome;}voidstartGame(){if(playState==PlayState.playing)return;world.removeAll(world.children.query<Ball>());world.removeAll(world.children.query<Bat>());world.removeAll(world.children.query<Brick>());playState=PlayState.playing;score.value=0;//Addthislineworld.add(Ball(difficultyModifier:difficultyModifier,radius:ballRadius,position:size/2,velocity:Vector2((rand.nextDouble()-0.5)*width,height*0.2,).normalized()..scale(height/4),),);world.add(Bat(size:Vector2(batWidth,batHeight),cornerRadius:constRadius.circular(ballRadius/2),position:Vector2(width/2,height*0.95),),);world.addAll([for(vari=0;i <brickColors.length;i++)for(varj=1;j <=5;j++)Brick(position:Vector2((i+0.5)*brickWidth+(i+1)*brickGutter,(j+2.0)*brickHeight+j*brickGutter,),color:brickColors[i],),]);}@overridevoidonTap(){super.onTap();startGame();}@overrideKeyEventResultonKeyEvent(KeyEventevent,Set<LogicalKeyboardKey>keysPressed,){super.onKeyEvent(event,keysPressed);switch(event.logicalKey){caseLogicalKeyboardKey.arrowLeft:world.children.query<Bat>().first.moveBy(-batStep);caseLogicalKeyboardKey.arrowRight:world.children.query<Bat>().first.moveBy(batStep);caseLogicalKeyboardKey.space:caseLogicalKeyboardKey.enter:startGame();}returnKeyEventResult.handled;}@overrideColorbackgroundColor()=>constColor(0xfff2e8cf);}
By addingscore
to the game, you tie the game's state to Flutter state management.
- Modify the
Brick
class to add a point to the score when the player breaks bricks.
lib/src/components/brick.dart
import'package:flame/collisions.dart';import'package:flame/components.dart';import'package:flutter/material.dart';import'../brick_breaker.dart';import'../config.dart';import'ball.dart';import'bat.dart';classBrickextendsRectangleComponentwithCollisionCallbacks,HasGameReference<BrickBreaker>{Brick({requiredsuper.position,requiredColorcolor}):super(size:Vector2(brickWidth,brickHeight),anchor:Anchor.center,paint:Paint()..color=color..style=PaintingStyle.fill,children:[RectangleHitbox()],);@overridevoidonCollisionStart(Set<Vector2>intersectionPoints,PositionComponentother,){super.onCollisionStart(intersectionPoints,other);removeFromParent();game.score.value++;//Addthislineif(game.world.children.query<Brick>().length==1){game.playState=PlayState.won;game.world.removeAll(game.world.children.query<Ball>());game.world.removeAll(game.world.children.query<Bat>());}}}
Make a good-looking game
Now that you can keep score in Flutter, it's time to put together the widgets to make it look good.
- Create
score_card.dart
inlib/src/widgets
and add the following.
lib/src/widgets/score_card.dart
import'package:flutter/material.dart';classScoreCardextendsStatelessWidget{constScoreCard({super.key,requiredthis.score});finalValueNotifier<int>score;@overrideWidgetbuild(BuildContextcontext){returnValueListenableBuilder<int>(valueListenable:score,builder:(context,score,child){returnPadding(padding:constEdgeInsets.fromLTRB(12,6,12,18),child:Text('Score: $score'.toUpperCase(),style:Theme.of(context).textTheme.titleLarge!,),);},);}}
- Create
overlay_screen.dart
inlib/src/widgets
and add the following code.
This adds more polish to the overlays using the power of theflutter_animate
package to add some movement and style to the overlay screens.
lib/src/widgets/overlay_screen.dart
import'package:flutter/material.dart';import'package:flutter_animate/flutter_animate.dart';classOverlayScreenextendsStatelessWidget{constOverlayScreen({super.key,requiredthis.title,requiredthis.subtitle});finalStringtitle;finalStringsubtitle;@overrideWidgetbuild(BuildContextcontext){returnContainer(alignment:constAlignment(0,-0.15),child:Column(mainAxisSize:MainAxisSize.min,children:[Text(title,style:Theme.of(context).textTheme.headlineLarge,).animate().slideY(duration:750.ms,begin:-3,end:0),constSizedBox(height:16),Text(subtitle,style:Theme.of(context).textTheme.headlineSmall).animate(onPlay:(controller)=>controller.repeat()).fadeIn(duration:1.seconds).then().fadeOut(duration:1.seconds),],),);}}
To get a more in-depth look at the power offlutter_animate
, check out theBuilding next generation UIs in Flutter codelab.
This code changed a lot in theGameApp
component. First, to enableScoreCard
to access thescore
, you convert it from aStatelessWidget
toStatefulWidget
. The addition of the score card requires the addition of aColumn
to stack the score above the game.
Second, to enhance the welcome, game over, and won experiences, you added the newOverlayScreen
widget.
lib/src/widgets/game_app.dart
import'package:flame/game.dart';import'package:flutter/material.dart';import'package:google_fonts/google_fonts.dart';import'../brick_breaker.dart';import'../config.dart';import'overlay_screen.dart';//Addthisimportimport'score_card.dart';//AndthisonetooclassGameAppextendsStatefulWidget{//ModifythislineconstGameApp({super.key});@override//Addfromhere...State<GameApp>createState()=>_GameAppState();}class_GameAppStateextendsState<GameApp>{latefinalBrickBreakergame;@overridevoidinitState(){super.initState();game=BrickBreaker();}//Tohere.@overrideWidgetbuild(BuildContextcontext){returnMaterialApp(debugShowCheckedModeBanner:false,theme:ThemeData(textTheme:GoogleFonts.pressStart2pTextTheme().apply(bodyColor:constColor(0xff184e77),displayColor:constColor(0xff184e77),),),home:Scaffold(body:Container(decoration:constBoxDecoration(gradient:LinearGradient(begin:Alignment.topCenter,end:Alignment.bottomCenter,colors:[Color(0xffa9d6e5),Color(0xfff2e8cf)],),),child:SafeArea(child:Padding(padding:constEdgeInsets.all(16),child:Center(child:Column(//Modifyfromhere...children:[ScoreCard(score:game.score),Expanded(child:FittedBox(child:SizedBox(width:gameWidth,height:gameHeight,child:GameWidget(game:game,overlayBuilderMap:{PlayState.welcome.name:(context,game)=>constOverlayScreen(title:'TAP TO PLAY',subtitle:'Use arrow keys or swipe',),PlayState.gameOver.name:(context,game)=>constOverlayScreen(title:'G A M E O V E R',subtitle:'Tap to Play Again',),PlayState.won.name:(context,game)=>constOverlayScreen(title:'Y O U W O N ! ! !',subtitle:'Tap to Play Again',),},),),),),],),//Tohere.),),),),),);}}
With that all in place, you should now be able to run this game on any of the six Flutter target platforms. The game should resemble the following.
11. Congratulations
Congratulations, you succeeded in building a game with Flutter and Flame!
You built a game using the Flame 2D game engine and embedded it in a Flutter wrapper. You used Flame's Effects to animate and remove components. You used Google Fonts and Flutter Animate packages to make the whole game look well designed.
What's next?
Check out some of these codelabs...
- Building next generation UIs in Flutter
- Take your Flutter app from boring to beautiful
- Adding in-app purchases to your Flutter app
Further reading
Except as otherwise noted, the content of this page is licensed under theCreative Commons Attribution 4.0 License, and code samples are licensed under theApache 2.0 License. For details, see theGoogle Developers Site Policies. Java is a registered trademark of Oracle and/or its affiliates.