1. Introduction
Learn how to build a platformer game with Flutter and Flame! In the Doodle Dash game, inspired by Doodle Jump, you play as either Dash (the Flutter mascot), or her best friend Sparky (the Firebase mascot), and try to reach as high as possible by jumping on platforms.
What you'll learn
- How to build a cross-platform game in Flutter.
- How to create reusable game components that can render and update as part of the Flame game loop.
- How to control and animate your character's (called a sprite) movements through game physics.
- How to add and manage collision detection.
- How to add keyboard and touch input as controls for the game.
Prerequisites
This codelab assumes that you have some Flutter experience. If not, you can learn the basics with the Your First Flutter App codelab.
What you'll build
This codelab guides you through building a game called Doodle Dash: a platformer game featuring Dash, the Flutter mascot or Sparky, the Firebase mascot (the rest of this codelab references Dash, but the steps also apply to Sparky). Your game will have the following features:
- A sprite that can move horizontally and vertically
- Randomly generated platforms
- A gravity effect that pulls down your sprite
- Game menus
- In-game controls like pause and replay
- The ability to keep score
Game play
Doodle Dash is played by moving Dash left and right, jumping on platforms, and using power ups to increase her ability throughout the game. You start the game by choosing the initial difficulty level (1 through 5), and clicking Start.
Levels
There are 5 levels in the game. Each level (after level 1) unlocks new features.
- Level 1 (default): This level spawns
NormalPlatform
andSpringBoard
platforms. When created, any platform has a 20% chance of being a moving platform. - Level 2 (score >= 20): Adds
BrokenPlatform
that can only be jumped on once. - Level 3 (score >= 40): Unlocks the
NooglerHat
power up. This special platform lasts for 5 seconds and increases Dash's jumping ability by 2.5x her normal velocity. She also wears a cool noogler hat for those 5 seconds. - Level 4 (score >=80): Unlocks the
Rocket
power up. This special platform, represented by a rocket ship, makes Dash invincible. It also increases Dash's jumping ability by 3.5x her normal velocity. - Level 5 (score >= 100): Unlocks
Enemy
platforms. If Dash collides with an enemy, it's an automatic game over.
Platform types by level
Level 1 (default)
|
|
Level 2(score >= 20) | Level 3(score >= 40) | Level 4(score >= 80) | Level 5(score >= 100) |
|
|
|
|
Losing the game
There are two ways to lose the game:
- Dash falls below the bottom of the screen.
- Dash collides with an enemy (enemies spawn at level 5).
Power ups
Power ups enhance the character's playing ability such as increasing her jumping velocity, or allowing her to become "invincible" against enemies, or both. Doodle Dash has two power up options. Only one power up is active at a time.
- The noogler hat power up increases Dash's jumping ability by 2.5x her normal jumping height. Plus she wears a noogler hat during the power up.
- The rocket ship power up makes Dash invincible against enemy platforms (colliding with an enemy has no effect) and increases her jumping ability by 3.5x her normal jumping height. She flies in a rocket until gravity overcomes her velocity and she lands on a platform.
2. Get the codelab starter code
Download the initial version of your project from GitHub:
- From the command line, clone the GitHub repository into a
flutter-codelabs
directory:
git clone https://github.com/flutter/codelabs.git flutter-codelabs
The code for this codelab is in the flutter-codelabs/flame-building-doodle-dash
directory. The directory contains completed project code for each step in the codelab.
Import the starter app
- Import the
flutter-codelabs/flame-building-doodle-dash/step_02
directory into your preferred IDE.
Install packages:
- All required packages, such as Flame, have already been added to the project
pubspec.yaml
file. If your IDE doesn't automatically install dependencies, open a command line terminal and from the root of the Flutter project, run the following command to retrieve the project dependencies:
flutter pub get
Set up your Flutter development environment
To complete this codelab you need the following:
3. Tour the code
Next, take a tour of the code.
Review the lib/game/doodle_dash.dart
file, which contains the DoodleDash game that extends FlameGame
. You register your components with the FlameGame
instance, the most basic component in Flame (similar to a Flutter Scaffold
), and it renders and updates all of its registered components during game play. Think of it as the central nervous system of your game.
What are components? Similar to how a Flutter app is made up of Widgets
, a FlameGame
is made up of Components
: all the building blocks that make up the game. (Components, much like Flutter widgets, can also have child components.) A character's sprite, the game background, the object responsible for generating new game components (enemies, for example), are all components. In fact, the FlameGame
itself is a Component
; Flame calls this the Flame Component System.
Components inherit from an abstract Component
class. Implement Component
's abstract methods to create the mechanics of the FlameGame
class. For example, you'll often see the following methods implemented throughout DoodleDash:
onLoad
: asynchronously initializes a component (similar to Flutter'sinitState
method)update
: updates a component with each tick of the game loop (similar to Flutter'sbuild
method)
Additionally, the add
method registers components with the Flame engine.
For example, the lib/game/world.dart
file contains the World
class, which extends ParallaxComponent
to render the game background. This class takes a list of image assets, and renders them in layers, making each layer move at a different velocity to make it look more realistic. The DoodleDash
class contains a ParallaxComponent
instance and adds it to the game in the DoodleDash onLoad
method:
lib/game/world.dart
class World extends ParallaxComponent<DoodleDash> {
@override
Future<void> onLoad() async {
parallax = await gameRef.loadParallax(
[
ParallaxImageData('game/background/06_Background_Solid.png'),
ParallaxImageData('game/background/05_Background_Small_Stars.png'),
ParallaxImageData('game/background/04_Background_Big_Stars.png'),
ParallaxImageData('game/background/02_Background_Orbs.png'),
ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
ParallaxImageData('game/background/01_Background_Squiggles.png'),
],
fill: LayerFill.width,
repeat: ImageRepeat.repeat,
baseVelocity: Vector2(0, -5),
velocityMultiplierDelta: Vector2(0, 1.2),
);
}
}
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
final World _world = World();
...
@override
Future<void> onLoad() async {
await add(_world);
...
}
}
State management
The lib/game/managers
directory contains three files that handle state management for Doodle Dash: game_manager.dart
, object_manager.dart
, and level_manager.dart
.
The GameManager
class (in game_manager.dart
) keeps track of the overall game state and scorekeeping.
The ObjectManager
class (in object_manager.dart
) manages where and when platforms are spawned and removed. You'll be adding to this class later.
And, finally, the LevelManager
class (in level_manager.dart
), manages the game's difficulty level along with any relevant game configurations for when players level up. The game provides five difficulty levels—the player advances to the next level when reaching one of the scoring milestones. Each increase in level increases difficulty and the further Dash must jump. Since gravity is constant throughout the game, the jump speed is gradually increased to account for the further distance.
The player's score increases whenever the player passes a platform. When the player achieves certain point thresholds, the game levels up and unlocks new special platforms that make the game more fun and challenging.
4. Add a Player to the game
This step adds the character to the game (in this case, Dash). The player controls the character and all of the logic lives in the Player
class (in the player.dart
file). The Player
class extends Flame's SpriteGroupComponent
class, which contains abstract methods you override to implement custom logic. This includes loading assets and sprites, positioning the player (horizontally and vertically), configuring collision detection, and accepting user input.
Loading assets
Dash is displayed with different sprites, representing different versions of the character and power ups. For example, the following icons show Dash and Sparky facing center, left, and right.
Flame's SpriteGroupComponent
allows you to manage multiple sprite states with the sprites
property, as you'll see in the _loadCharacterSprites
method.
In the
Player
class, add the following lines to the onLoad
method to load the sprite assets and set the Player
's sprite state to face forward:
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await _loadCharacterSprites(); // Add this line
current = PlayerState.center; // Add this line
}
Examine the code for loading the sprites and assets in _loadCharacterSprites
. This code could be implemented directly in the onLoad
method, but placing it in a separate method organizes the source code and makes it more readable. This method assigns a map to the sprites
property that pairs each character state to a loaded sprite asset, as shown below:
lib/game/sprites/player.dart
Future<void> _loadCharacterSprites() async {
final left = await gameRef.loadSprite('game/${character.name}_left.png');
final right = await gameRef.loadSprite('game/${character.name}_right.png');
final center =
await gameRef.loadSprite('game/${character.name}_center.png');
final rocket = await gameRef.loadSprite('game/rocket_4.png');
final nooglerCenter =
await gameRef.loadSprite('game/${character.name}_hat_center.png');
final nooglerLeft =
await gameRef.loadSprite('game/${character.name}_hat_left.png');
final nooglerRight =
await gameRef.loadSprite('game/${character.name}_hat_right.png');
sprites = <PlayerState, Sprite>{
PlayerState.left: left,
PlayerState.right: right,
PlayerState.center: center,
PlayerState.rocket: rocket,
PlayerState.nooglerCenter: nooglerCenter,
PlayerState.nooglerLeft: nooglerLeft,
PlayerState.nooglerRight: nooglerRight,
};
}
Update the player component
Flame calls a component's update
method once every tick (or frame) of the event loop to re-draw each game component that has changed (similar to Flutter's build
method). Next, add logic in the Player
class's update
method to position the character on the screen.
Add the following code to the
Player
's update
method to calculate the character's current velocity and position:
lib/game/sprites/player.dart
void update(double dt) {
// Add lines from here...
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed; // ... to here.
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) { // Add lines from here...
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
} // ... to here.
// Core gameplay: Add gravity
position += _velocity * dt; // Add this line
super.update(dt);
}
Before moving the player, the update
method checks to ensure that the game isn't in a non-playable state where the player shouldn't move, like during the initial state (when the game first loads) or the game over state.
If the game is in a playable state, Dash's position is calculated using the equation new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick)
or, as seen in the code:
position += _velocity * dt
Another key aspect when building Doodle Dash is making sure to include infinite side boundaries. Dash can then jump off the left edge of the screen and re-enter from the right and vice versa.
This is implemented by checking whether Dash's position has exceeded either the left or right edge of the screen and, if so, repositioning her on the opposite edge.
Key events
Initially, Doodle Dash runs on the web and desktop, so it needs to support keyboard input so that players can control the character's movement. The onKeyEvent
method allows the Player
component to recognize arrow key presses to determine whether Dash should be looking and moving to the left or right.
Dash faces left when moving to the left | Dash faces right when moving to the right |
Next, implement Dash's ability to move horizontally (as defined in the _hAxisInput
variable). You'll also make it so that Dash faces the direction in which she's moving.
Modify the
Player
class's moveLeft
and moveRight
methods to define Dash's current direction:
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
current = PlayerState.left; // Add this line
_hAxisInput += movingLeftInput; // Add this line
}
void moveRight() {
_hAxisInput = 0;
current = PlayerState.right; // Add this line
_hAxisInput += movingRightInput; // Add this line
}
Modify the
Player
class's onKeyEvent
method to call the moveLeft
or moveRight
methods respectively, when the left- or right-arrow keys are pressed:
lib/game/sprites/player.dart
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
_hAxisInput = 0;
// Add lines from here...
if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
moveLeft();
}
if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
moveRight();
} // ... to here.
// During development, it's useful to "cheat"
if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
// jump();
}
return true;
}
Now that the Player
class is functional, the Doodle Dash game can use it.
In the DoodleDash file, import
sprites.dart
, which makes the Player
class available:
lib/game/doodle_dash.dart
import 'sprites/sprites.dart'; // Add this line
Create a
Player
instance in the DoodleDash
class:
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
DoodleDash({super.children});
final World _world = World();
LevelManager levelManager = LevelManager();
GameManager gameManager = GameManager();
int screenBufferSpace = 300;
ObjectManager objectManager = ObjectManager();
late Player player; // Add this line
...
}
Next, initialize and configure
Player
jump speed based on the player's selected difficulty level, and add the Player
component to the FlameGame
. Fill out the setCharacter
method with the following code:
lib/game/doodle_dash.dart
void setCharacter() {
player = Player( // Add lines from here...
character: gameManager.character,
jumpSpeed: levelManager.startingJumpSpeed,
);
add(player); // ... to here.
}
Call the
setCharacter
method at the beginning of initializeGameStart
.
lib/game/doodle_dash.dart
void initializeGameStart() {
setCharacter(); // Add this line
...
}
Also, in
initializeGameStart
, call resetPosition
on the player so that it moves back to the starting position each time a game starts.
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
player.resetPosition(); // Add this line
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
...
}
Run the app. Start a game and Dash appears on screen!
Problems?
If your app isn't running correctly, look for typos. If needed, use the code at the following links to get back on track.
5. Add platforms
This step adds platforms (for Dash to land on and jump off of) and the collision detection logic to determine when Dash should jump.
First, examine the Platform
abstract class:
lib/game/sprites/platform.dart
abstract class Platform<T> extends SpriteGroupComponent<T>
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
bool isMoving = false;
Platform({
super.position,
}) : super(
size: Vector2.all(100),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
What's a hitbox?
Every platform component introduced in Doodle Dash extends the Platform<T>
abstract class, which is a SpriteComponent
with a hitbox. The hitbox allows a sprite component to detect when it collides with other objects with hitboxes. Flame supports a variety of hitbox shapes, such as rectangles, circles, and polygons. For example, Doodle Dash uses a rectangular hitbox for a platform, and a circular hitbox for Dash. Flame handles the math that calculates the collision.
The Platform
class adds a hitbox and collision callbacks to all subtypes.
Add a standard platform
The Platform
class adds platforms to the game. A normal platform is represented by one of 4 randomly chosen visuals: a monitor, phone, terminal, or a laptop. The choice of the visual doesn't affect the platform's behavior.
|
Add a regular, static platform by adding a
NormalPlatformState
enum and a NormalPlatform
class:
lib/game/sprites/platform.dart
enum NormalPlatformState { only } // Add lines from here...
class NormalPlatform extends Platform<NormalPlatformState> {
NormalPlatform({super.position});
final Map<String, Vector2> spriteOptions = {
'platform_monitor': Vector2(115, 84),
'platform_phone_center': Vector2(100, 55),
'platform_terminal': Vector2(110, 83),
'platform_laptop': Vector2(100, 63),
};
@override
Future<void>? onLoad() async {
var randSpriteIndex = Random().nextInt(spriteOptions.length);
String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);
sprites = {
NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
};
current = NormalPlatformState.only;
size = spriteOptions[randSprite]!;
await super.onLoad();
}
} // ... to here.
Next, spawn platforms for the character to interact with.
The ObjectManager
class extends Flame's Component
class and generates Platform
objects throughout the game. Implement the ability to spawn platforms in the ObjectManager
's update
and onMount
methods.
Spawn platforms in the
ObjectManager
class by creating a new method called _semiRandomPlatform
. You'll update this method later to return different types of platforms, but for now, just return a NormalPlatform
:
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) { // Add lines from here...
return NormalPlatform(position: position);
} // ... to here.
Override the
ObjectManager
's update
method, and use the _semiRandomPlatform
method to generate a platform and add it to the game:
lib/game/managers/object_manager.dart
@override // Add lines from here...
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
// Losing the game: Add call to _maybeAddEnemy()
// Powerups: Add call to _maybeAddPowerup();
}
super.update(dt);
} // ... to here.
Do the same in the ObjectManager
's onMount
method, so that when the game first runs, the _semiRandomPlatform
method generates a starting platform and adds it to the game.
Add the
onMount
method and with the following code:
lib/game/managers/object_manager.dart
@override // Add lines from here...
void onMount() {
super.onMount();
var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;
var currentY =
gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;
for (var i = 0; i < 9; i++) {
if (i != 0) {
currentX = _generateNextX(100);
currentY = _generateNextY();
}
_platforms.add(
_semiRandomPlatform(
Vector2(
currentX,
currentY,
),
),
);
add(_platforms[i]);
}
} // ... to here.
For example, as shown in the following code, the configure
method enables the Doodle Dash game to reconfigure the minimum and maximum distance between platforms and enables specialty platforms when the difficulty level increases:
lib/game/managers/object_manager.dart
void configure(int nextLevel, Difficulty config) {
minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;
for (int i = 1; i <= nextLevel; i++) {
enableLevelSpecialty(i);
}
}
The DoodleDash
instance (in the initializeGameStart
method), creates an ObjectManager
that's initialized, configured based on difficulty level, and added to the Flame game:
lib/game/doodle_dash.dart
void initializeGameStart() {
gameManager.reset();
if (children.contains(objectManager)) objectManager.removeFromParent();
levelManager.reset();
player.resetPosition();
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
add(objectManager);
objectManager.configure(levelManager.level, levelManager.difficulty);
}
The ObjectManager
appears again in the checkLevelUp
method. When the player levels up, the ObjectManager
reconfigures its platform generation parameters based on the difficulty level.
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
}
}
Hot reload
(or restart if you're testing on web) to activate the changes. (Save the file, use the button in your IDE or, from the command line, enter
r
to hot reload.) Start a game and Dash and some platforms appear on screen:
Problems?
If your app isn't running correctly, look for typos. If needed, use the code at the following links to get back on track.
6. Core gameplay
Now that you've implemented the individual Player
and Platform
widgets, you can start pulling everything together. This step implements core functionality, collision detection, and camera movement.
Gravity
To make the game more realistic, Dash needs to be acted upon by gravity, a force pulling Dash downward as she jumps. In our version of Doodle Dash, gravity remains a constant, positive value, always pulling Dash downwards. In the future, however, you might choose to change gravity to create other effects.
In the
Player
class, add a _gravity
property with a value of 9:
lib/game/sprites/player.dart
class Player extends SpriteGroupComponent<PlayerState>
with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {
...
Character character;
double jumpSpeed;
final double _gravity = 9; // Add this line
@override
Future<void> onLoad() async {
...
}
...
}
Modify the
Player
's update
method to add the _gravity
variable to impact Dash's vertical velocity:
lib/game/sprites/player.dart
void update(double dt) {
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed;
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) {
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
}
_velocity.y += _gravity; // Add this line
position += _velocity * dt;
super.update(dt);
}
Collision detection
Flame supports collision detection out of the box. To enable it in your Flame game, add the HasCollisionDetection
mixin. If you examine the DoodleDash
class, you'll see that this mixin has already been added:
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
}
Next, add collision detection to individual game components using the CollisionCallbacks
mixin. This mixin gives a component access to the onCollision
callback. A collision of two objects with hitboxes triggers the onCollision
callback and passes a reference to the object it's colliding with, so you can implement logic for how your object should react.
Recall from the previous step that the Platform
abstract class already has the CollisionCallbacks
mixin and a hitbox. The Player
class also has the CollisionCallbacks
mixin, so you only have to add a CircleHitbox
to the Player
class. Dash's hitbox is actually a circle, since Dash is more circular than rectangular.
In the
Player
class, import sprites.dart
so that it has access to the various Platform
classes:
lib/game/sprites/player.dart
import 'sprites.dart';
Add a
CircleHitbox
to the Player
class's onLoad
method:
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await add(CircleHitbox()); // Add this line
await _loadCharacterSprites();
current = PlayerState.center;
}
Dash needs a jump method so that she can jump when she collides with a platform.
Add a
jump
method that takes an optional specialJumpSpeed
:
lib/game/sprites/player.dart
void jump({double? specialJumpSpeed}) {
_velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}
Override the
Player
's onCollision
method by adding the following code:
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
}
}
}
This callback calls Dash's jump
method whenever she falls downward and collides with the top of a NormalPlatform
. The isMovingDown && isCollidingVertically
statement ensures that Dash moves up through the platforms without triggering a jump.
Camera movement
The camera should follow Dash as she moves upward in the game, but it should remain static when Dash falls down.
In Flame, if the "world" is larger than the screen, use the camera's worldBounds
to add boundaries that tell Flame which part of the world should be shown. To give the appearance that the camera is moving upward while staying fixed horizontally, adjust the top and bottom world bounds on each update based on the player's position, but keep the left and right bounds the same.
In the
DoodleDash
class, add the following code to the update
method to enable the camera to follow Dash during game play:
lib/game/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isIntro) {
overlays.add('mainMenuOverlay');
return;
}
if (gameManager.isPlaying) {
checkLevelUp();
// Add lines from here...
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
} // ... to here.
}
}
Next, the Player
position and camera boundaries must be reset to their origin whenever there's a game restart.
Add the following code in the
initializeGameStart
method:
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
// Add the lines from here...
player.reset();
camera.worldBounds = Rect.fromLTRB(
0,
-_world.size.y,
camera.gameSize.x,
_world.size.y +
screenBufferSpace,
);
camera.followComponent(player);
// ... to here.
player.resetPosition();
...
}
Increase jump speed on level up
The last piece of core gameplay requires Dash's jump speed to increase whenever the difficulty level increases and the platforms are spawned at further distances from each other.
Add a call to the
setJumpSpeed
method and provide the jump speed associated with the current level:
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
player.setJumpSpeed(levelManager.jumpSpeed); // Add this line
}
}
Hot reload
(or restart on web) to activate the changes. (Save the file, use the button in your IDE or, from the command line, enter
r
to hot reload.):
Problems?
If your app isn't running correctly, look for typos. If needed, use the code at the following links to get back on track.
7. More on platforms
Now that the ObjectManager
generates platforms for Dash to jump off of, you can give her some exciting special platforms.
Next, add the BrokenPlatform
and SpringBoard
classes. As the names suggest, a BrokenPlatform
breaks after one jump, and a SpringBoard
provides a trampoline that has Dash bouncing higher and faster.
|
|
Like the Player
class, each of these platform classes depends on enums
to represent their current state.
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken }
A change in a platform's current
state also changes the sprite that appears within the game. Define the mapping between the State
enum and image assets on the sprites
property to correlate which sprite is assigned to each state.
Add a
BrokenPlatformState
enum and the BrokenPlatform
class:
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken } // Add lines from here...
class BrokenPlatform extends Platform<BrokenPlatformState> {
BrokenPlatform({super.position});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <BrokenPlatformState, Sprite>{
BrokenPlatformState.cracked:
await gameRef.loadSprite('game/platform_cracked_monitor.png'),
BrokenPlatformState.broken:
await gameRef.loadSprite('game/platform_monitor_broken.png'),
};
current = BrokenPlatformState.cracked;
size = Vector2(115, 84);
}
void breakPlatform() {
current = BrokenPlatformState.broken;
}
} // ... to here.
Add a
SpringState
enum and the SpringBoard
class:
lib/game/sprites/platform.dart
enum SpringState { down, up } // Add lines from here...
class SpringBoard extends Platform<SpringState> {
SpringBoard({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <SpringState, Sprite>{
SpringState.down:
await gameRef.loadSprite('game/platform_trampoline_down.png'),
SpringState.up:
await gameRef.loadSprite('game/platform_trampoline_up.png'),
};
current = SpringState.up;
size = Vector2(100, 45);
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isCollidingVertically) {
current = SpringState.down;
}
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
current = SpringState.up;
}
} // ... to here.
Next, enable these special platforms in ObjectManager
. As special platforms, you don't want to see them in the game all of the time, so conditionally spawn them based on probability: 15% for SpringBoard
and 10% for BrokenPlatform
.
In
ObjectManager
, inside the _semiRandomPlatform
method, before the statement returning a NormalPlatform
, add the following code to conditionally return a special platform:
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) {
if (specialPlatforms['spring'] == true && // Add lines from here...
probGen.generateWithProbability(15)) {
return SpringBoard(position: position);
}
if (specialPlatforms['broken'] == true &&
probGen.generateWithProbability(10)) {
return BrokenPlatform(position: position);
} // ... to here.
return NormalPlatform(position: position);
}
Part of the fun with playing a game is unlocking new challenges and features as you level up.
You want the springboard to be populated from the very beginning at level 1, but once Dash reaches level 2, she unlocks the BrokenPlatform
, making the game a bit more difficult.
In the
ObjectManager
class, modify the enableLevelSpecialty
method (which is currently a stub) by adding a switch
statement that enables SpringBoard
platforms for level 1 and BrokenPlatform
for level 2:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) { // Add lines from here...
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
} // ... to here.
}
Next, give the platforms the ability to move back-and-forth horizontally. In the
Platform
abstract class**,** add the following _move
method:
lib/game/sprites/platform.dart
void _move(double dt) {
if (!isMoving) return;
final double gameWidth = gameRef.size.x;
if (position.x <= 0) {
direction = 1;
} else if (position.x >= gameWidth - size.x) {
direction = -1;
}
_velocity.x = direction * speed;
position += _velocity * dt;
}
If the platform is moving, it will change its movement in the opposite direction when it reaches the edge of the game screen. Like Dash, the platform's position is determined by multiplying _direction
with the platform speed
to get velocity. Then, multiply velocity with time-elapsed
and add the resulting distance to the platform's current position
.
Override the
Platform
class's update
method to call the _move
method:
lib/game/sprites/platform.dart
@override
void update(double dt) {
_move(dt);
super.update(dt);
}
To trigger the moving
Platform
, in the onLoad
method randomly set the isMoving
boolean to true
20% of the time.
lib/game/sprites/platform.dart
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
final int rand = Random().nextInt(100); // Add this line
if (rand > 80) isMoving = true; // Add this line
}
Finally, in
Player
, modify the Player
class's onCollision
method to recognize a collision with a Springboard
or a BrokenPlatform
. Notice that a SpringBoard
calls jump
with a 2x speed multiplier and BrokenPlatform
only calls jump
if its state is .cracked
, instead of .broken
(already jumped on):
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) { // Add lines from here...
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
} // ... to here.
}
}
Restart the app. Start a game to see the moving platforms,
SpringBoard
, and BrokenPlatform
!
Problems?
If your app isn't running correctly, look for typos. If needed, use the code at the following links to get back on track.
8. Losing the game
This step adds losing conditions to the Doodle Dash game. There are two ways that the player can lose:
- Dash misses a platform and falls below the bottom of the screen.
- Dash collides with an
Enemy
platform.
Before you can implement either "game over" condition, you need to add logic that sets the DoodleDash game state to be gameOver
.
In the
DoodleDash
class**,** add an onLose
method that is called whenever the game should end. It sets the game state, removes the player from the screen, and activates the **Game Over** menu/overlay.
lib/game/sprites/doodle_dash.dart
void onLose() { // Add lines from here...
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
} // ... to here.
Game Over menu:
At the top of
DoodleDash
's update
method, add the following code to stop the game from updating when the game state is GameOver
:
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isGameOver) { // Add lines from here...
return;
} // ... to here.
...
}
Also, in the
update
method, call onLose
when the player has fallen below the bottom of the screen.
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
...
if (gameManager.isPlaying) {
checkLevelUp();
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
}
// Add lines from here...
if (player.position.y >
camera.position.y +
_world.size.y +
player.size.y +
screenBufferSpace) {
onLose();
} // ... to here.
}
}
Enemies can come in all sorts of shapes and sizes; in Doodle Dash, they're indicated by a trashcan or an error folder icon. Players should avoid colliding with one of these because it's an immediate game over.
|
Create an enemy platform type by adding an
EnemyPlatformState
enum and the EnemyPlatform
class:
lib/game/sprites/platform.dart
enum EnemyPlatformState { only } // Add lines from here...
class EnemyPlatform extends Platform<EnemyPlatformState> {
EnemyPlatform({super.position});
@override
Future<void>? onLoad() async {
var randBool = Random().nextBool();
var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';
sprites = <EnemyPlatformState, Sprite>{
EnemyPlatformState.only:
await gameRef.loadSprite('game/$enemySprite.png'),
};
current = EnemyPlatformState.only;
return super.onLoad();
}
} // ... to here.
The EnemyPlatform
class extends the Platform
supertype. The ObjectManager
spawns and manages enemy platforms as it does for all other platforms.
In
ObjectManager
, add the following code to spawn and manage the enemy platforms:
lib/game/managers/object_manager.dart
final List<EnemyPlatform> _enemies = []; // Add lines from here...
void _maybeAddEnemy() {
if (specialPlatforms['enemy'] != true) {
return;
}
if (probGen.generateWithProbability(20)) {
var enemy = EnemyPlatform(
position: Vector2(_generateNextX(100), _generateNextY()),
);
add(enemy);
_enemies.add(enemy);
_cleanupEnemies();
}
}
void _cleanupEnemies() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
remove(_enemies.first);
_enemies.removeAt(0);
}
} // ... to here.
The ObjectManager
maintains a list of enemy objects, _enemies
. The _maybeAddEnemy
spawns enemies with 20 percent probability and adds the object to the enemy list. The _cleanupEnemies()
method removes stale EnemyPlatform
objects that are no longer visible.
In
ObjectManager
, spawn enemy platforms by calling _maybeAddEnemy()
in the the update
method:
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy(); // Add this line
}
super.update(dt);
}
Add to
Player
's onCollision
method to check whether it's colliding with an EnemyPlatform
. If so, call the onLose()
method.
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform) { // Add lines from here...
gameRef.onLose();
return;
} // ... to here.
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
}
Finally, modify the
ObjectManager
's enableLevelSpecialty
method to add level 5 to the switch
statement:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 5: // Add lines from here...
enableSpecialty('enemy');
break; // ... to here.
}
}
Now that you've made the game more challenging, hot reload
to activate the changes. (Save the files, use the button in your IDE or, from the command line, enter
r
to hot reload.):
Beware of those broken folder enemies. They're sneaky. They blend in with the background!
Problems?
If your app isn't running correctly, look for typos. If needed, use the code at the following links to get back on track.
9. Power Ups
This step adds enhanced game features to power up Dash throughout the game. Doodle Dash has two power up options: a Noogler Hat or a Rocket ship. You can think of these power ups as another type of special platform. As Dash jumps through the game, her speed accelerates when she collides with and possesses either a Noogler hat or Rocket ship powerup.
|
|
The Noogler Hat spawns at level 3, once the player achieves a score >= 40. When Dash collides with the hat, she wears the Noogler hat and receives an acceleration boost 2.5x her normal speed. This lasts for 5 seconds.
The Rocket spawns at level 4, when the player achieves a score >= 80. When Dash collides with the Rocket, her sprite is replaced by a rocket ship, and she receives an acceleration boost 3.5x her normal speed until she lands on a platform. A bonus is that she's also invincible against enemies when she has the Rocket powerup.
The Noogler Hat and Rocket sprites extend the PowerUp
abstract class. Like the Platform
abstract class, the PowerUp
abstract class, shown below, also includes sizing and a hitbox.
lib/game/sprites/powerup.dart
abstract class PowerUp extends SpriteComponent
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
double get jumpSpeedMultiplier;
PowerUp({
super.position,
}) : super(
size: Vector2.all(50),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
Create a
Rocket
class that extends the PowerUp
abstract class. When Dash collides with the rocket, she receives a boost of 3.5 times her normal speed.
lib/game/sprites/powerup.dart
class Rocket extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 3.5;
Rocket({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/rocket_1.png');
size = Vector2(50, 70);
}
} // ... to here.
Create a
NooglerHat
class that extends the PowerUp
abstract class. When Dash collides with the NooglerHat
, she receives an acceleration boost of 2.5 times her normal speed. The increased acceleration lasts for 5 seconds.
lib/game/sprites/powerup.dart
class NooglerHat extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 2.5;
NooglerHat({
super.position,
});
final int activeLengthInMS = 5000;
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/noogler_hat.png');
size = Vector2(75, 50);
}
} // ... to here.
Now that you've implemented the NooglerHat
and Rocket
power ups, update the ObjectManager
to spawn them in the game.
Modify the
ObjectManger
class to add a list that keeps track of the spawned powerups, along with two new methods: _maybePowerup
and _cleanupPowerups
to spawn and remove the new powerup platforms.
lib/game/managers/object_manager.dart
final List<PowerUp> _powerups = []; // Add lines from here...
void _maybeAddPowerup() {
if (specialPlatforms['noogler'] == true &&
probGen.generateWithProbability(20)) {
var nooglerHat = NooglerHat(
position: Vector2(_generateNextX(75), _generateNextY()),
);
add(nooglerHat);
_powerups.add(nooglerHat);
} else if (specialPlatforms['rocket'] == true &&
probGen.generateWithProbability(15)) {
var rocket = Rocket(
position: Vector2(_generateNextX(50), _generateNextY()),
);
add(rocket);
_powerups.add(rocket);
}
_cleanupPowerups();
}
void _cleanupPowerups() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
if (_powerups.first.parent != null) {
remove(_powerups.first);
}
_powerups.removeAt(0);
}
} // ... to here.
The _maybeAddPowerup
method spawns a noogler hat 20% of the time or a rocket 15% of the time. _cleanupPowerups
method is called to remove power ups that are below the bottom bounds of the screen.
Modify the
ObjectManager
update
method to call _maybePowerup
on each tick of the game loop.
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy();
_maybeAddPowerup(); // Add this line
}
super.update(dt);
}
Modify the
enableLevelSpecialty
method to add two new cases in the switch statement: one to enable NooglerHat
on level 3 and another enabling Rocket
on level 4:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 3: // Add lines from here...
enableSpecialty('noogler');
break;
case 4:
enableSpecialty('rocket');
break; // ... to here.
case 5:
enableSpecialty('enemy');
break;
}
}
Add the following boolean getters to the
Player
class. If Dash has an active powerup, it can be represented by a variety of different states. These getters make it easier to check which powerup is active.
lib/game/sprites/player.dart
bool get hasPowerup => // Add lines from here...
current == PlayerState.rocket ||
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter;
bool get isInvincible => current == PlayerState.rocket;
bool get isWearingHat =>
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter; // ... to here.
Modify the
Player
's onCollision
method to react to a collision with either a NooglerHat
or Rocket
. This code also makes sure that Dash only activates a new powerup if she doesn't already have one.
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform && !isInvincible) {
gameRef.onLose();
return;
}
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
if (!hasPowerup && other is Rocket) { // Add lines from here...
current = PlayerState.rocket;
other.removeFromParent();
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} else if (!hasPowerup && other is NooglerHat) {
if (current == PlayerState.center) current = PlayerState.nooglerCenter;
if (current == PlayerState.left) current = PlayerState.nooglerLeft;
if (current == PlayerState.right) current = PlayerState.nooglerRight;
other.removeFromParent();
_removePowerupAfterTime(other.activeLengthInMS);
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} // ... to here.
}
If Dash collides with a rocket, the PlayerState
changes to Rocket
and enables Dash to jump with a 3.5x jumpSpeedMultiplier
.
If Dash collides with a Noogler hat, depending on the current PlayerState
direction (.center
, .left
, or .right
), the PlayerState
changes to the corresponding Noogler PlayerState
with her wearing the Noogler hat and giving her an increased 2.5x jumpSpeedMultiplier
. The _removePowerupAfterTime
method removes the powerup after 5 seconds and changes the PlayerState
from the powerup states back to center
.
The call to other.removeFromParent
removes the Noogler Hat or Rocket sprite platforms from the screen to reflect that Dash has acquired the powerup.
Modify the Player class's
moveLeft
and moveRight
methods to account for the NooglerHat
sprite. You don't need to account for the Rocket
powerup because that sprite faces the same direction regardless of the direction of travel.
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerLeft;
} else if (!hasPowerup) { // ... to here.
current = PlayerState.left;
} // Add this line
_hAxisInput += movingLeftInput;
}
void moveRight() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerRight;
} else if (!hasPowerup) { //... to here.
current = PlayerState.right;
} // Add this line
_hAxisInput += movingRightInput;
}
Dash is invincible against enemies when she has the Rocket
powerup, so avoid ending the game during this time.
Modify the
onCollision
callback to check whether Dash isInvincible
before triggering a game over when colliding with an EnemyPlatform
:
lib/game/sprites/player.dart
if (other is EnemyPlatform && !isInvincible) { // Modify this line
gameRef.onLose();
return;
}
Restart the app and play a game to see the power ups in action.
Problems?
If your app isn't running correctly, look for typos. If needed, use the code at the following links to get back on track.
10. Overlays
A Flame game can be wrapped in a widget, making it easy to integrate it alongside other widgets in a Flutter app. You can also display Flutter widgets as overlays on top of your Flame Game. This is convenient for non-gameplay components that don't depend on the game loop such as the menus, pause screen, buttons, and sliders.
The score display seen in-game along with all of the menus in Doodle Dash are regular Flutter widgets, not Flame components. All of the widgets' code is located in lib/game/widgets
, for example, the Game Over menu is just a column containing other widgets like Text
and ElevatedButton
, as shown in the following code:
lib/game/widgets/game_over_overlay.dart
class GameOverOverlay extends StatelessWidget {
const GameOverOverlay(this.game, {super.key});
final Game game;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.background,
child: Center(
child: Padding(
padding: const EdgeInsets.all(48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Game Over',
style: Theme.of(context).textTheme.displayMedium!.copyWith(),
),
const WhiteSpace(height: 50),
ScoreDisplay(
game: game,
isLight: true,
),
const WhiteSpace(
height: 50,
),
ElevatedButton(
onPressed: () {
(game as DoodleDash).resetGame();
},
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(200, 75),
),
textStyle: MaterialStateProperty.all(
Theme.of(context).textTheme.titleLarge),
),
child: const Text('Play Again'),
),
],
),
),
),
);
}
}
To use a widget as an overlay in a Flame game, define an overlayBuilderMap
property on GameWidget
with a key
that represents the overlay (as a String
), and the value
of a widget function that returns a widget, as shown in the following code:
lib/main.dart
GameWidget(
game: game,
overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
'gameOverlay': (context, game) => GameOverlay(game),
'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
'gameOverOverlay': (context, game) => GameOverOverlay(game),
},
)
Once added, an overlay can be used anywhere in the game. Show an overlay using overlays.add
and hide it using overlays.remove
, as shown in the following code:
lib/game/doodle_dash.dart
void resetGame() {
startGame();
overlays.remove('gameOverOverlay');
}
void onLose() {
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
}
11. Mobile support
Doodle Dash is built on Flutter and Flame, so it already runs across Flutter's supported platforms. But, so far, Doodle Dash only supports keyboard-based input. For devices that don't have a keyboard, like mobile phones, it's easy to add on-screen touch control buttons to the overlay.
Add a boolean state variable to the
GameOverlay
that determines when the game runs on a mobile platform:
lib/game/widgets/game_overlay.dart
class GameOverlayState extends State<GameOverlay> {
bool isPaused = false;
// Add this line
final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
@override
Widget build(BuildContext context) {
...
}
}
Now, display left and right directional buttons in the overlay when the game runs on mobile. Similar to the "key events" logic from step 4, tapping the left button moves Dash to the left. Tapping the right button moves her to the right.
In
GameOverlay
's build
method, add an isMobile
section that follows the same behavior as described in step 4: tapping the left button invokes moveLeft
and the right button invokes moveRight
. Releasing either button calls resetDirection
and causes Dash to idle horizontally.
lib/game/widgets/game_overlay.dart
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned(... child: ScoreDisplay(...)),
Positioned(... child: ElevatedButton(...)),
if (isMobile) // Add lines from here...
Positioned(
bottom: MediaQuery.of(context).size.height / 4,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveLeft();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_left, size: 64),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveRight();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_right, size: 64),
),
),
),
],
),
),
), // ... to here.
if (isPaused)
...
],
),
);
}
That's it! Now the Doodle Dash app auto detects what kind of platform it's running on and switches its inputs accordingly.
Run the app on iOS or Android to see the directional buttons in action.
Problems?
If your app isn't running correctly, look for typos. If needed, use the code at the following links to get back on track.
12. Next steps
Congratulations!
You've completed this codelab and have learned how to build a game in Flutter using the Flame game engine.
What we've covered:
- How to use the Flame package to create a platformer game, including:
- Adding a character
- Adding a variety of platform types
- Implementing collision detection
- Adding a gravity component
- Defining camera movement
- Creating enemies
- Creating power ups
- How to detect the platform the game is running on and...
- How to use that info to switch between keyboard and touch input controls
Resources
We hope that you have learned more about making games in Flutter!
You might also find the following resources helpful and maybe even inspiring:
- Flame documentation and the Flame package on pub.dev
- Basics of the Flame game engine YouTube video by Lukas Klingsbo
- Simple Platformer, The Flame + Flutter game series by DevKage
- Dino Run; The Flutter Game Development series by DevKage
- Spacescape, The Flutter Game Development series by DevKage
- Flutter Games
- Flutter's Casual Games toolkit page and corresponding Getting Started template for the Casual Games toolkit (The Casual Games toolkit doesn't use the Flame engine, but is designed to support mobile ads and in-game app purchases.)
- Build your own game in Flutter, a Casual Games toolkit video
- The Flutter Puzzle Hack page (a competition that ran in January 2022) and the resulting winners video