Home Science & Technology Create A Breakout Game With Flame and Forge2D – Part 3

Create A Breakout Game With Flame and Forge2D – Part 3

80
0


This article is Part 3 of a three-part tutorial on creating a Flutter Breakout game with Flame and Forge2D.

The companion articles to this tutorial are:

In Part 2 of this tutorial, you expanded your knowledge of Forge2D. You learned how to create the brick wall and paddle for your Breakout game. You also learned how to add user input controls and create joints to connect rigid bodies.

Your game is beginning to look like the Breakout game.

Breakout Game

In this tutorial, you’ll complete your Breakout game by adding gameplay logic and skinning the game. Also, you’ll learn:

  • Add game rules and behaviors.
  • Add gameplay logic.
  • Create a Forge2D sensor.
  • Use the Flame Widgets Overlay API to add Flutter widgets to control the game.
  • Add user tap input.
  • Use Canvas to skin your game by painting the rigid BodyComponent in the game to give them color.
Note: This tutorial assumes you’re familiar with the Dart Canvas class. If you aren’t familiar with Canvas, Wilberforce Uwadiegwu’s article Flutter Canvas API: Getting Started is a great introduction.

Getting Started

You can use the project you worked on in Part 2 of this tutorial or the starter project for this tutorial. Download it by clicking the Download Materials button at the top or bottom of the tutorial.

Both of these projects have a Forge2D ball bouncing inside an arena. Also, you have a brick wall, a paddle the player can control and collision detection that removes bricks from the wall. This is the starting point for this tutorial.

Adding Game Rules and Behaviors

Games have rules and must pose a challenge to players. Unfortunately, your game at this point doesn’t have any rules and it isn’t much of a challenge — when the player misses the ball, it bounces off the bottom wall and continues. If the player destroys all the bricks, the ball continues to bounce in an empty arena. You’ll now add gameplay logic to your game.

Adding Gameplay Logic

A Breakout game is over when the player misses the ball with the paddle. Game rules also include that when a player destroys all the bricks, the player wins and the game is over. You’ll now add this gameplay logic to your game.

Open forge2d_game_world.dart and add the following enum at the top of the file before the Forge2dGameWorld class definition:


enum GameState {
 initializing,
 ready,
 running,
 paused,
 won,
 lost,
}

These will be the six states for your game. Now, add a gameState property to Forge2dGameWorld and set the initial state to initializing.


 GameState gameState = GameState.initializing;

Next, set the game state to ready once the game completes initializing. Add the following state change as the last line in _initializeGame:


 gameState = GameState.ready;

You now have the first two states of your game in place.

Winning and losing are two critical game states. First, you’ll see how to determine when the player loses the game and set the game state to GameState.lost. Then, you’ll add a check for when all the bricks in the wall are destroyed and set the game state to GameState.won.

Adding a Forge2D Sensor

You’ll now add a Forge2D sensor for the dead zone to detect when the player has missed the ball. What’s a dead zone? It’s a region at the bottom of the arena. The dead zone will use a Fixture sensor that detects collisions without generating a response. Restated, this means you can get notified of a collision, but the colliding body will pass through without responding to the collision.

Create a dead_zone.dart file in the components folder and add the following lines of code to the file:


import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

import '../forge2d_game_world.dart';
import 'ball.dart';

// 1
class DeadZone extends BodyComponent<Forge2dGameWorld> with ContactCallbacks {
 final Size size;
 final Vector2 position;

 DeadZone({
 required this.size,
 required this.position,
 });

 @override
 Body createBody() {
 final bodyDef = BodyDef()
 ..type = BodyType.static
 ..userData = this
 ..position = position;

 final zoneBody = world.createBody(bodyDef);

 final shape = PolygonShape()
 ..setAsBox(
 size.width / 2.0,
 size.height / 2.0,
 Vector2.zero(),
 0.0,
 );

 // 2
 zoneBody.createFixture(FixtureDef(shape)..isSensor = true);

 return zoneBody;
 }

 // 3
 @override
 void beginContact(Object other, Contact contact) {
 if (other is Ball) {
 gameRef.gameState = GameState.lost;
 }
 }
}
  1. The declaration for DeadZone body should look familiar to you. DeadZone needs to react to the ball coming into contact with it, so add the ContactCallbacks mixin.
  2. Setting the isSensor flag of the FixtureDef to true makes this body distinctive. Sensor bodies detect collisions but don’t react to them.
  3. If the ball comes into contact with the dead zone, set the gameState to GameState.lost. Forge2dGameWorld will detect the game state change in the game loop update method.

The game loop needs to check the game state and act appropriately. In this case, when the player loses, the game needs to stop. With the Flame game engine, pausing the engine is the appropriate action.

Open forge2d_game_world.dart and add these imports:


import 'package:flame/extensions.dart';

import 'components/dead_zone.dart';

Then add the DeadZone body to the _initializeGame routine between BrickWall and Paddle.


 final deadZoneSize = Size(size.x, size.y * 0.1);
 final deadZonePosition = Vector2(
 size.x / 2.0,
 size.y - (size.y * 0.1) / 2.0,
 );

 final deadZone = DeadZone(
 size: deadZoneSize,
 position: deadZonePosition,
 );
 await add(deadZone);

You want the dead zone to fill the arena area at the bottom of the screen. First, set the deadZoneSize to be the same width and 10% of the height of the game area. Next, set the deadZonePosition, so the DeadZone center is at the bottom of the game area.

Now with a dead zone in place, you can properly position the paddle. The paddle should move along the top edge of the dead zone. Change paddlePosition to place the bottom edge of the paddle at the top edge of the dead zone.


 final paddlePosition = Vector2(
 size.x / 2.0,
 size.y - deadZoneSize.height - paddleSize.height / 2.0,
 );

Add the following update routine to forge2d_game_world.dart. The update routine will listen for changes to the game state.


 @override
 void update(double dt) {
 super.update(dt);

 if (gameState == GameState.lost) {
 pauseEngine();
 }
 }

Flame calls your update routine from the game loop, allowing you to make changes or respond to events such as game state changes. Here, you’re calling pauseEngine to stop the execution of the game loop.

Build and run the project. Now, you’ll get a white rectangular area at the bottom of the screen, which is the dead zone sensor body. The game stops when the ball comes into contact with the dead zone.

Ball Contacts Dead Zone

Why is the DeadZone body white? For that matter, why are all the Forge2D bodies white? Forge2D’s BodyComponent default behavior is to render body fixture shapes, making them visible. You can turn off this default behavior by setting the renderBody property of a BodyComponent to false.

Open dead_zone.dart and add the following line of code at the top of the DeadZone class after the constructor.


 @override
 bool get renderBody => false;

Build and run the project. The dead zone body remains, but Forge2D is not rendering the fixture shapes on the body. In an upcoming section, you’ll learn more about rendering bodies when you “skin” the game.

Invisible Dead Zone

Adding the Win Game State

Your game knows when a player loses, but not when they win. So you’re now going to add the remaining game states to your game. Begin by adding the win state. Players win when they destroy all the bricks.

Open brick_wall.dart and add the following code to update just after the for loop that removed destroyed bricks from the wall:


 if (children.isEmpty) {
 gameRef.gameState = GameState.won;
 }

Now, open forge2d_game_world.dart and change the if statement condition in the update function to check gameState for either GameState.lost or GameState.won.


 if (gameState == GameState.lost || gameState == GameState.won) {
 pauseEngine();
 }

Your game will now recognize when the player wins or loses, and the gameplay stops.

Adding Start and Reset Controls

Your game begins to play when you run the app, regardless of whether the player is ready. When the game ends with a loss or a win, there’s no way to replay the game without restarting the app. This behavior isn’t user-friendly. You’ll now add controls for the player to start and replay the game.

You’ll use overlays to present standard Flutter widgets to the user.

Flame Overlays

The Flame Widgets Overlay API provides a convenient method for layering Flutter widgets on top of your game widget. In your Breakout game, the Widgets Overlay API is perfect for communicating to the player when the game is ready to begin and getting input from the player about replaying the game.

You define an Overlay in an overlay builder map provided to the GameWidget. The map declares a String and an OverlayWidgetBuilder builder method for each overlay. Flame calls the overlay builder method and adds the overlay when you add the overlay to the active overlays list.

You’ll start by adding a simple overlay informing the player the game is ready to begin.

Adding a Game-Ready Overlay

Create an overlay_builder.dart file in the ui folder and add the following lines of code to the file:


import 'package:flutter/material.dart';
import '../forge2d_game_world.dart';

// 1
class OverlayBuilder {
 OverlayBuilder._();

 // 2
 static Widget preGame(BuildContext context, Forge2dGameWorld game) {
 return const PreGameOverlay();
 }
}

// 3
class PreGameOverlay extends StatelessWidget {
 const PreGameOverlay({super.key});

 @override
 Widget build(BuildContext context) {
 return const Center(
 child: Text(
 'Tap Paddle to Begin',
 style: TextStyle(
 color: Colors.white,
 fontSize: 24,
 ),
 ),
 );
 }
}

Let’s examine this code:

  1. OverlayBuilder is a class container for scoping the overlay builder methods.
  2. Declare a static overlay builder method named pregame to instantiate the PreGameOverlay widget.
  3. Declare a PreGameOverlay widget as a stateless widget. The PreGameOverlay widget is a widget that centers a Text widget in the GameWidget container with text instructing the player to tap the paddle to begin the game.

Open main_game_page.dart and include the following import to get the OverlayBuilder.preGame builder method:


import 'overlay_builder.dart';

And provide GameWidget with an overlay builder map:


 child: GameWidget(
 game: forge2dGameWorld,
 overlayBuilderMap: const {
 'PreGame': OverlayBuilder.preGame,
 },
 ),

You’ve created the overlay and notified Flame how to build the overlay. Now you can use the overlay in your game. You need to present the pregame overlay when the game state is GameState.ready.

Open forge2d_game_world.dart and add the following line of code at the end of _initializeGame after setting gameState to GameState.ready:


 gameState = GameState.ready;
 overlays.add('PreGame');

Adding Player Tap Input

Currently, a force is applied to the ball after the game is initialized and the Breakout game begins. Unfortunately, this isn’t player-friendly. The simplest way to let the player control the game start is to wait until they tap the game widget.

Open forge2d_game_world.dart and remove the call to _ball.body.applyLinearImpulse from onLoad. The onLoad method will now only call _initializeGame.


 @override
 Future<void> onLoad() async {
 await _initializeGame();
 }

Now, include the following import and add the HasTappables mixin to your Forge2dGameWorld:


 import 'package:flame/input.dart';

 class Forge2dGameWorld extends Forge2DGame with HasDraggables, HasTappables {

Next, add a new onTapDown method to Forge2dGameWorld.


 @override
 void onTapDown(int pointerId, TapDownInfo info) {
 if (gameState == GameState.ready) {
 overlays.remove('PreGame');
 _ball.body.applyLinearImpulse(Vector2(-10.0, -10.0));
 gameState = GameState.running;
 }
 super.onTapDown(pointerId, info);
 }

When a player taps the screen, onTapDown gets called. If the game is in the ready and waiting state, remove the pregame overlay and apply the linear impulse force that begins the ball’s movement. Finally, don’t forget to change the game state to GameState.running.

Before trying your new pregame overlay, move the ball’s starting position. Otherwise, the overlay text will be on top of the ball. Inside the _initialize method, change the starting position of the ball to this:


 final ballPosition = Vector2(size.x / 2.0, size.y / 2.0 + 10.0);

 _ball = Ball(
 radius: 0.5,
 position: ballPosition,
 );
 await add(_ball);

Build and run the project. Your Breakout game is waiting for you to tap the screen to begin.

PreGame Overlay

Very cool! But you still need a way to reset the game and play again.

Adding a Game-Over Overlay

The game-over overlay will be like the game-ready overlay you created. Yet, while the overlay will be similar, you must modify your game to reset the game components to their initial pregame states.

Begin by opening forge2d_game_world.dart and add the following resetGame method.


 Future<void> resetGame() async {}

resetGame is a placeholder method that you’ll come back to shortly.

Now, open overlay_builder.dart and create a new postGame overlay builder method in OverlayBuilder.


 static Widget postGame(BuildContext context, Forge2dGameWorld game) {
 assert(game.gameState == GameState.lost || game.gameState == GameState.won);

 final message = game.gameState == GameState.won ? 'Winner!' : 'Game Over';
 return PostGameOverlay(message: message, game: game);
 }

The postGame overlay will congratulate the player on a win or let them know the game is over on a loss.

Now, declare a PostGameOverlay stateless widget to display the appropriate postgame message to the player and give them a replay button to reset the game. Add the PostGameOverlay class at the bottom of overlay_builder.dart.


class PostGameOverlay extends StatelessWidget {
 final String message;
 final Forge2dGameWorld game;

 const PostGameOverlay({
 super.key,
 required this.message,
 required this.game,
 });

 @override
 Widget build(BuildContext context) {
 return Center(
 child: Column(
 mainAxisAlignment: MainAxisAlignment.center,
 children: [
 Text(
 message,
 style: const TextStyle(
 color: Colors.white,
 fontSize: 24,
 ),
 ),
 const SizedBox(height: 24),
 _resetButton(context, game),
 ],
 ),
 );
 }

 Widget _resetButton(BuildContext context, Forge2dGameWorld game) {
 return OutlinedButton.icon(
 style: OutlinedButton.styleFrom(
 side: const BorderSide(
 color: Colors.blue,
 ),
 ),
 onPressed: () => game.resetGame(),
 icon: const Icon(Icons.restart_alt_outlined),
 label: const Text('Replay'),
 );
 }
}

The PostGameOverlay widget should feel familiar. The postgame overlay is defined using Flutter widgets, a Text widget to display a message and a button to reset the game.

Notice the onPressed callback method in the reset button. The overlay builder method API provides a reference to the game loop. Your overlay can use this reference to send a message to the game loop to reset the game. Pretty cool, huh?

Resetting the Game

You now have a postgame overlay, but you must make your game resettable.

First, open forge2d_game_world.dart and make all the Forge2D bodies instance variables. These will be late final variables because the bodies aren’t created until the game is loading.


 late final Arena _arena;
 late final Paddle _paddle;
 late final DeadZone _deadZone;
 late final BrickWall _brickWall;

After you’ve created the instance variables, fix the variable initializations in _initializeGame.


 Future<void> _initializeGame() async {
 _arena = Arena();
 await add(_arena);

 final brickWallPosition = Vector2(0.0, size.y * 0.075);

 _brickWall = BrickWall(
 position: brickWallPosition,
 rows: 8,
 columns: 6,
 );
 await add(_brickWall);

 final deadZoneSize = Size(size.x, size.y * 0.1);
 final deadZonePosition = Vector2(
 size.x / 2.0,
 size.y - (size.y * 0.1) / 2.0,
 );

 _deadZone = DeadZone(
 size: deadZoneSize,
 position: deadZonePosition,
 );
 await add(_deadZone);

 const paddleSize = Size(4.0, 0.8);
 final paddlePosition = Vector2(
 size.x / 2.0,
 size.y - deadZoneSize.height - paddleSize.height / 2.0,
 );

 _paddle = Paddle(
 size: paddleSize,
 ground: _arena,
 position: paddlePosition,
 );
 await add(_paddle);

 final ballPosition = Vector2(size.x / 2.0, size.y / 2.0 + 10.0);

 _ball = Ball(
 radius: 0.5,
 position: ballPosition,
 );
 await add(_ball);

 gameState = GameState.ready;
 overlays.add('PreGame');
 }

Now, make the three Breakout game components — the ball, paddle and wall — resettable.

Open ball.dart and add the following reset method:


 void reset() {
 body.setTransform(position, angle);
 body.angularVelocity = 0.0;
 body.linearVelocity = Vector2.zero();
 }

In the reset method, you’re resetting the ball’s location back to its initial position and setting the angular and linear velocities to zero, a ball at rest.

Now, open paddle.dart and add this reset method:


 void reset() {
 body.setTransform(position, angle);
 body.angularVelocity = 0.0;
 body.linearVelocity = Vector2.zero();
 }

Finally, open brick_wall.dart and add this reset method:


 Future<void> reset() async {
 removeAll(children);
 await _buildWall();
 }

Now, open forge2d_game_world.dart. First, add a call to show the postgame overlay when the game state is lost or won, inside the update function:


 if (gameState == GameState.lost || gameState == GameState.won) {
 pauseEngine();
 overlays.add('PostGame');
 }

Then, add the following code to resetGame.


 Future<void> resetGame() async {
 gameState = GameState.initializing;

 _ball.reset();
 _paddle.reset();
 await _brickWall.reset();

 gameState = GameState.ready;

 overlays.remove(overlays.activeOverlays.first);
 overlays.add('PreGame');

 resumeEngine();
 }

This method sets the game state to initializing and then calls the reset methods on the three dynamic components. After the game components reset, set the game state to ready, replace the postgame overlay with the pregame overlay and resume the game.

Now, open main_game_page.dart and add the postgame overlay to the overlayBuilderMap.


 overlayBuilderMap: const {
 'PreGame': OverlayBuilder.preGame,
 'PostGame': OverlayBuilder.postGame,
 },

Build and run the project. The game now congratulates the player for winning or the game is over. In both cases, the player can press a button to replay the game.

PostGame Overlay

Tip: Testing the win-game state can be tedious, if you have to destroy all the bricks. To make winning the game easier, set the rows and columns of the brick wall to a smaller value.

Congratulations! You have a functional Breakout game.

Your game has the needed components and functionality for a Breakout game. You’ve added gameplay logic for winning and losing a game. You’ve added game states to control setting up, playing and resetting the game. But, something’s missing. The game isn’t beautiful.

You must “skin” your game to make it pretty.

Skinning Your Game

Several methods can make your game prettier. Flame supports Sprites and other tools to skin games. Also, Forge2D’s BodyComponent has a render method you can override to provide your custom render method. In the following sections, you’ll learn to create a custom render method for the ball, paddle and brick wall.

Rendering the Ball

Forge2D is two-dimensional. A ball is a three-dimensional sphere. So what can you do to give the ball a 3D look? Gradients! Rendering the ball with a radial gradient will provide the 3D illusion needed.

Open ball.dart and add the following imports:


import 'package:flutter/rendering.dart';

import 'package:flame/extensions.dart';

Now, add the following gradient code after the Ball constructor:


 final _gradient = RadialGradient(
 center: Alignment.topLeft,
 colors: [
 const HSLColor.fromAHSL(1.0, 0.0, 0.0, 1.0).toColor(),
 const HSLColor.fromAHSL(1.0, 0.0, 0.0, 0.9).toColor(),
 const HSLColor.fromAHSL(1.0, 0.0, 0.0, 0.4).toColor(),
 ],
 stops: const [0.0, 0.5, 1.0],
 radius: 0.95,
 );

Using HSL, hue, saturation and light, color declarations can be easier to read and understand than other color models. These three colors are shades of white at 100%, 90% and 40% lightness. This RadialGradient uses these shades of white to give the ball a cue-ball appearance.

Next, add the following render method to the Ball component:


 //1
 @override
 void render(Canvas canvas) {

 // 2
 final circle = body.fixtures.first.shape as CircleShape;

 // 3
 final paint = Paint()
 ..shader = _gradient.createShader(Rect.fromCircle(
 center: circle.position.toOffset(),
 radius: radius,
 ))
 ..style = PaintingStyle.fill;

 // 4
 canvas.drawCircle(circle.position.toOffset(), radius, paint);
 }

The render method is simple. Let’s take a closer look.

  1. You can override the Forge2D BodyComponent render method to customize drawing the body. The render method passes you a reference to the Dart Canvas, where you can draw the body.
  2. The ball body has a single CircleShape fixture. Get the shape information from the body.
  3. Create a Paint object with the gradient to use when drawing the ball.
  4. Draw the ball with the radial gradient.

Build and run the project. Notice the shading effect on the ball? Pretty cool, huh?

Rendered Ball

Rendering the Paddle

Rendering the paddle is like how you rendered the ball, but easier. To paint the paddle, you’ll use a single opaque color.

Open paddle.dart and add the following imports:


import 'package:flutter/rendering.dart';

Then add the following render method to the Paddle component:


 @override
 void render(Canvas canvas) {
 final shape = body.fixtures.first.shape as PolygonShape;

 final paint = Paint()
 ..color = const Color.fromARGB(255, 80, 80, 228)
 ..style = PaintingStyle.fill;

 canvas.drawRect(
 Rect.fromLTRB(
 shape.vertices[0].x,
 shape.vertices[0].y,
 shape.vertices[2].x,
 shape.vertices[2].y,
 ),
 paint);
 }

The PolygonShape has the vertices of the paddle in shape.vertices. The first point is the upper left-hand corner of the rectangle. The lower right-hand corner is the third point. You can use these points to draw the paddle on the canvas.

Build and run the project. You’ve colorized the paddle.

Rendered Paddle

That leaves coloring the brick wall.

Rendering the Brick Wall

Rendering the brick wall has two components: the rainbow of colors used to color the wall and the painting of each brick. The brick wall handles creating the bricks making up the wall. The brick wall will maintain the list of colors for the wall and assign each brick an appropriate color. Each brick will be responsible for rendering itself with its assigned color.

Start by opening brick.dart and add the following import:


import 'package:flame/components.dart';

Next, add a color property to Brick:


 final Size size;
 final Vector2 position;
 final Color color;

 Brick({
 required this.size,
 required this.position,
 required this.color,
 });

Then, add the following render method:


 @override
 void render(Canvas canvas) {
 if (body.fixtures.isEmpty) {
 return;
 }

 final rectangle = body.fixtures.first.shape as PolygonShape;

 final paint = Paint()
 ..color = color
 ..style = PaintingStyle.fill;

 canvas.drawRect(
 Rect.fromCenter(
 center: rectangle.centroid.toOffset(),
 width: size.width,
 height: size.height,
 ),
 paint);
 }

Notice the check to ensure a Fixture is on the brick body. We need this condition because the brick could be in the process of being destroyed when Forge2D calls the render method.

Next, open brick_wall.dart and add the following private method to generate an evenly dispersed set of colors.


 // Generate a set of colors for the bricks that span a range of colors.
 // This color generator creates a set of colors spaced across the
 // color spectrum.
 static const transparency = 1.0;
 static const saturation = 0.85;
 static const lightness = 0.5;

 List<Color> _colorSet(int count) => List<Color>.generate(
 count,
 (int index) => HSLColor.fromAHSL(
 transparency,
 index / count * 360.0,
 saturation,
 lightness,
 ).toColor(),
 growable: false,
 );

The _colorSet routine generates a set of colors by dividing the range of color hues evenly over the rows of bricks. This rainbow of colors is reminiscent of the Atari Breakout game.

Now, add a private local variable after the BrickWall constructor to store the colors.


 late final List<Color> _colors;

Modify the onLoad method to create the color set.


 @override
 Future<void> onLoad() async {
 _colors = _colorSet(rows);
 await _buildWall();
 }

Finally, update the call to Brick to include the assigned color for the brick in the _buildWall function.


 await add(Brick(
 size: brickSize,
 position: brickPosition,
 color: _colors[i],
 ));

Build and run the project.

Completed Breakout Game

Congratulations! You’ve created a Breakout game using Flutter, Flame and Forge2D.

Where to Go From Here?

Download the completed project files by clicking the Download Materials button at the top or bottom of the tutorial.

The Breakout game you created is the bare minimum functionality for a game. Tweaking and fine-tuning a game can make your game more challenging. Here are some ideas:

  • Add collision detection code to keep the ball’s velocity within a range that makes the game challenging.
  • Add levels to the game with parameters that make each successive level more difficult.
  • Add scoring to the game by assigning values to the bricks.
  • Add a timer to the game.

You can make many additions to take your Breakout game to the next level. Be creative and let your imagination be your guide!

Previous articleThe RedMagic 8 Pro gaming phone with Snapdragon 8 Gen 2 arrives
Next articleThe Xiaomi 13 Ultra offers a shockingly impressive periscope lens