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's Breakout. 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.

A screen recording of a game being played. The game has been sped up significantly.

What you'll learn

  • How the basics of Flame work, starting with GameWidget.
  • How to use a game loop.
  • How Flame's Components work. They are akin to Flutter's Widgets.
  • How to handle collisions.
  • How to use Effects to animate Components.
  • How to overlay Flutter Widgets 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 and flutter_animate to recreate the feel of 80s arcade gaming.

2. Set up your Flutter environment

Editor

To simplify this codelab, it presumes that Visual 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 appropriate action 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.

A screenshot of VS Code with some Flutter code

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.

A drawing depicting a laptop and a phone attached to the laptop by a cable. The laptop is labelled as the

For example: say you're using a Windows laptop to develop your Flutter app. You then choose Android as your development target. 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.

You might be tempted to choose the web as your development target. This has a downside during development: you lose Flutter's Stateful 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 on docs.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:

  1. Flutter SDK
  2. Visual Studio Code with the Flutter plugin
  3. Compiler software for your chosen development target. (You need Visual Studio to target Windows or Xcode 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

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.

  1. Launch Visual Studio Code.
  2. Open the command palette (F1 or Ctrl+Shift+P or Shift+Cmd+P) then type "flutter new". When it appears, select the Flutter: New Project command.

A screenshot of VS Code with

  1. Select Empty 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\.

A screenshot of VS Code with Empty Application shown as selected as part of the new application flow

  1. Name your project brick_breaker. The remainder of this codelab presumes you named your app brick_breaker.

A screen shot of VS Code with

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.

  1. In the left pane of VS Code, click on Explorer and open the pubspec.yaml file.

A partial screen shot of VS Code with arrows highlighting the location of the pubspec.yaml file

  1. Replace the contents of this file with the following:

pubspec.yaml

name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

The pubspec.yaml file specifies basic information about your app, such as its current version, its dependencies, and the assets with which it will ship.

  1. Open main.dart file in lib/ directory.

A partial screen shot of VS Code with an arrow showing the location of the main.dart file

  1. Replace the contents of this file with the following:

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. 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!

A screen shot showing a brick_breaker application window that is completely black.

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.

Create a file called config.dart in a new directory called lib/src. This file will gain more constants in the following steps.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 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 a PlayArea component first.

  1. Create a file called play_area.dart in a new directory called lib/src/components.
  2. 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';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
      : super(
          paint: Paint()..color = const Color(0xfff2e8cf),
        );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Where Flutter has Widgets, Flame has Components. 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 mutable RenderObject 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.

  1. To control clutter, add a file containing all the components in this project. Create a components.dart file in lib/src/components and add the following content.

lib/src/components/components.dart

export 'play_area.dart';

The export directive plays the inverse role of import. 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's FlameGame.

  1. Create a file named brick_breaker.dart in lib/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';

class BrickBreaker extends FlameGame {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<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 adds letterboxing as required.

You expose the width and height of the game so that the children components, like PlayArea, can set themselves to the appropriate size.

In the onLoad overridden method, your code performs two actions.

  1. 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).
  2. Adds the PlayArea to the world. The world represents the game world. It projects all of its children through the CameraComponents view transformation.

Get the game on screen

To see all the changes you have made in this step, update your lib/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';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

After you make these changes, restart the game. The game should resemble the following figure.

A screen shot showing a brick_breaker application window  with a sand colored rectangle in the middle of the app window

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.

  1. Edit the contents of lib/src/config.dart file as follows.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

The design pattern of defining named constants as derived values will return many times in this codelab. This enables you to modify the top level gameWidth and gameHeight to explore how the game look and feel changes as result.

  1. Create the Ball component in a file called ball.dart in lib/src/components.

lib/src/components/ball.dart

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

class Ball extends CircleComponent {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill);

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }
}

Earlier, you defined the PlayArea using the RectangleComponent, so it stands to reason that more shapes exist. CircleComponent, like RectangleComponent, derives from PositionedComponent, so you can position the ball on the screen. More importantly, its position can be updated.

This component introduces the concept of velocity, or change in position over time. Velocity is a Vector2 object as velocity is both speed and direction. To update position, override the update method, which the game engine calls for every frame. The dt is the duration between the previous frame and this frame. This enables you to adapt to factors like different frame rates (60hz or 120hz) or long frames due to excessive computation.

Pay close attention to the position += velocity * dt update. This is how you implement updating a discrete simulation of motion over time.

  1. To include the Ball component in the list of components, edit the lib/src/components/components.dart file as follows.

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

Adding the ball to the world

You have a ball. Let's place it in the world and set it up to move around the play area.

Edit the lib/src/brick_breaker.dart file as follows.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math; // Add this import

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(                                             // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    debugMode = true;                                           // To here.
  }
}

This change adds the Ball component to the world. To set the ball's position to the center of the display area, the code first halves the size of the game, as Vector2 has operator overloads (* and /) to scale a Vector2 by a scalar value.

To set the ball's velocity involves more complexity. The intent is to move the ball down the screen in a random direction at a reasonable speed. The call to the normalized method creates a Vector2 object set to the same direction as the original Vector2, 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.

When you now run the game, it should resemble the following display.

A screen shot showing a brick_breaker application window with a blue circle on top of the sand colored rectangle. The blue circle is annotated with numbers indicated it's size and location on screen

Both the PlayArea component and the Ball component both have debugging information, but the background mattes crop the PlayArea's numbers. The reason everything has debugging information displayed is because you turned on debugMode 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 the HasCollisionDetection mixin to the BrickBreaker game as shown in the following code.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/game.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<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.

To start populating the game's hitboxes, modify the PlayArea component as shown below.

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
      : super(
          paint: Paint()..color = const Color(0xfff2e8cf),
          children: [RectangleHitbox()],                        // Add this parameter
        );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Adding a RectangleHitbox component as a child of the RectangleComponent will construct a hit box for collision detection that matches the size of the parent component. There is a factory constructor for RectangleHitbox called relative 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 the Ball component. It's the ball's behavior that has to change when it collides with the PlayArea.

Modify the Ball component as follows.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);                        // Add this parameter

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override                                                     // Add from here...
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

This example makes a major change with the addition of the onCollisionStart callback. The collision detection system added to BrickBreaker in the prior example calls this callback.

First, the code tests if the Ball collided with PlayArea. 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 an else 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.

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,

  1. Insert some constants in lib/src/config.dart file as follows.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

The batHeight and batWidth constants are self explanatory. The batStep 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. The batStep constant configures how far the bat steps for each left or right arrow key press.

  1. 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';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(
          anchor: Anchor.center,
          children: [RectangleHitbox()],
        );

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
        RRect.fromRectAndRadius(
          Offset.zero & size.toSize(),
          cornerRadius,
        ),
        _paint);
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    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 a PositionComponent, not a RectangleComponent nor a CircleComponent. This means this code needs to render the Bat on screen. To accomplish this, it overrides the render callback.

Looking closely at the canvas.drawRRect (draw rounded rectangle) call, and you might ask yourself, "where is the rectangle?" The Offset.zero & size.toSize() leverages an operator & overload on the dart:ui Offset class that creates Rects. This shorthand might confuse you at first, but you will see it frequently in lower level Flutter and Flame code.

Second, this Bat component is draggable using either finger or mouse depending on platform. To implement this functionality, you add the DragCallbacks mixin and override the onDragUpdate event.

Last, the Bat component needs to respond to keyboard control. The moveBy 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: Effects. By adding the MoveToEffect object as a child of this component, the player sees the bat animated to a new position. There are a collection of Effects available in Flame to perform a variety of effects.

The Effect's constructor arguments include a reference to the game getter. This is why you include the HasGameReference mixin on this class. This mixin adds a type-safe game accessor to this component to access the BrickBreaker instance at the top of the component tree.

  1. To make the Bat available to BrickBreaker, update the lib/src/components/components.dart file as follows.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

Add the bat to the world

To add the Bat component to the game world, update BrickBreaker as follows.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<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(Bat(                                              // Add from here...
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));          // To here

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here
}

The addition of the KeyboardEvents mixin and the overridden onKeyEvent 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 in Ball's collision detection code.

Time to fix that now. Edit 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';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(                                       // Modify from here...
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      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 the removeFromParent call with RemoveEffect. The RemoveEffect 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 the velocity update. It doesn't just reverse the y component of the velocity, as was done for the wall collisions. It also updates the x component in a way that depends on the relative position of the bat and ball at 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,

  1. Insert some constants in lib/src/config.dart file as follows.

lib/src/config.dart

import 'package:flutter/material.dart';                         // Add this import

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1)))
    / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. 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';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    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 a RectangleComponent, with both collision detection and a type-safe reference to the BrickBreaker 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 the Brick component accessible to BrickBreaker, edit lib/src/components/components.dart as follows.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

Add bricks to the world

Update 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';                                            // Add this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,                           // Add this parameter
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {                                // Modify from here...
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);          // To here.
    }
  }
}

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 the BrickBreaker game as follows.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        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: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.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.

A screen shot showing brick_breaker with ball, bat, and most of the bricks on the playing area. Each of the components has debugging labels

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.

  1. Modify the BrickBreaker game as follows.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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';

enum PlayState { welcome, playing, gameOver, won }              // Add this enumeration

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {   // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;                                    // Add from here...
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }                                                             // To here.

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;                              // Add from here...
  }

  void startGame() {
    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;                              // To here.

    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: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    world.addAll([                                              // Drop the await
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }                                                             // Drop the debugMode

  @override                                                     // Add from here...
  void onTap() {
    super.onTap();
    startGame();
  }                                                             // To here.

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:                            // Add from here...
      case LogicalKeyboardKey.enter:
        startGame();                                            // To here.
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);          // Add this override
}

This code changes a good deal of the BrickBreaker game. Adding the playState 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 in onLoad into onLoad and a new startGame 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.

  1. 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';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;
  final double difficultyModifier;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            }));                                                // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

This small change adds an onComplete callback to the RemoveEffect which triggers the gameOver play state. This should feel about right if the player allows the ball to escape off the bottom of the screen.

  1. Edit 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';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.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.

  1. Create a widgets directory under lib/src.
  2. 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';

class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.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 using GameWidget.controlled to construct and manage the BrickBreaker game instance and the new overlayBuilderMap argument to the GameWidget.

This overlayBuilderMap's keys must align with the overlays that the playState setter in BrickBreaker added or removed. Attempting to set an overlay that is not in this map leads to unhappy faces all around.

  1. 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';

void main() {
  runApp(const GameApp());
}

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 enable google_fonts to display.

Enabling font access

Add internet permission for Android

For Android, you must add Internet permission. Edit your AndroidManifest.xml as follows.

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android: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">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

Edit entitlement files for macOS

For macOS, you have two files to edit.

  1. Edit the DebugProfile.entitlements file to match the following code.

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="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/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. Edit the Release.entitlements file to match the following code

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</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.

  1. Modify the BrickBreaker game as follows.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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';

enum PlayState { welcome, playing, gameOver, won }

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final ValueNotifier<int> score = ValueNotifier(0);            // Add this line
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

  void startGame() {
    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;                                            // Add this line

    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: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }

  @override
  void onTap() {
    super.onTap();
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

By adding score to the game, you tie the game's state to Flutter state management.

  1. 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';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (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.

  1. Create score_card.dart in lib/src/widgets and add the following.

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({
    super.key,
    required this.score,
  });

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. Create overlay_screen.dart in lib/src/widgets and add the following code.

This adds more polish to the overlays using the power of the flutter_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';

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({
    super.key,
    required this.title,
    required this.subtitle,
  });

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(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),
          const SizedBox(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 of flutter_animate, check out the Building next generation UIs in Flutter codelab.

This code changed a lot in the GameApp component. First, to enable ScoreCard to access the score , you convert it from a StatelessWidget to StatefulWidget. The addition of the score card requires the addition of a Column to stack the score above the game.

Second, to enhance the welcome, game over, and won experiences, you added the new OverlayScreen 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';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

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.

A screen shot of brick_breaker showing the pre-game screen inviting the user to tap the screen to play the game

A screen shot of brick_breaker showing the game over screen overlaid on top of a bat and some of the bricks

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...

Further reading