Building a game with Flutter and Flame

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.

d1e75aa0e05c526.gif

Levels

There are 5 levels in the game. Each level (after level 1) unlocks new features.

  • Level 1 (default): This level spawns NormalPlatform and SpringBoard 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)

NormalPlatform

SpringBoard

Level 2(score >= 20)

Level 3(score >= 40)

Level 4(score >= 80)

Level 5(score >= 100)

BrokenPlatform

NooglerHat

Rocket

Enemy

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

a3c16fc17be25f6c.pngDownload the initial version of your project from GitHub:

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

a3c16fc17be25f6c.pngImport the starter app

  • Import the flutter-codelabs/flame-building-doodle-dash/step_02 directory into your preferred IDE.

a3c16fc17be25f6c.pngInstall 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's initState method)
  • update: updates a component with each tick of the game loop (similar to Flutter's build 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.

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

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

7068325e8b2f35fc.gif

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.

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

 }

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

a3c16fc17be25f6c.pngIn the DoodleDash file, import sprites.dart, which makes the Player class available:

lib/game/doodle_dash.dart

import 'sprites/sprites.dart';                                       // Add this line

a3c16fc17be25f6c.pngCreate 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 
  ...
}

a3c16fc17be25f6c.png 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.
}

a3c16fc17be25f6c.pngCall the setCharacter method at the beginning of initializeGameStart.

lib/game/doodle_dash.dart

void initializeGameStart() {
    setCharacter();                                                   // Add this line

    ...
}

a3c16fc17be25f6c.pngAlso, 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);

    ...
  }

a3c16fc17be25f6c.png Run the app. Start a game and Dash appears on screen!

ed15a9c6762595c9.png

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.

NormalPlatform

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

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

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

a3c16fc17be25f6c.pngAdd 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);
    }
  }

a3c16fc17be25f6c.png Hot reload 7f9a9e103c7b5e5.png (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:

7c6a6c6e630c42ce.png

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.

a3c16fc17be25f6c.png 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 {
    ...
  }
  ...
}

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

a3c16fc17be25f6c.png 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';

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

a3c16fc17be25f6c.png Add a jump method that takes an optional specialJumpSpeed:

lib/game/sprites/player.dart

void jump({double? specialJumpSpeed}) {
  _velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}

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

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

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

a3c16fc17be25f6c.pngAdd 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
    }
  }

a3c16fc17be25f6c.png Hot reload 7f9a9e103c7b5e5.png (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.):

2bc7c856064d74ca.gif

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.

BrokenPlatform

SpringBoard

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.

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

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

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

a3c16fc17be25f6c.pngIn 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.
}

a3c16fc17be25f6c.pngNext, 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.

a3c16fc17be25f6c.pngOverride 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);
}

a3c16fc17be25f6c.pngTo 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
}

a3c16fc17be25f6c.pngFinally, 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.
   }
 }

a3c16fc17be25f6c.png Restart the app. Start a game to see the moving platforms, SpringBoard, and BrokenPlatform!

d4949925e897f665.gif

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:

  1. Dash misses a platform and falls below the bottom of the screen.
  2. 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.

a3c16fc17be25f6c.pngIn 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:

6a79b43f4a1f780d.png

a3c16fc17be25f6c.pngAt 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.
   ...
}

a3c16fc17be25f6c.pngAlso, 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.

Enemy

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

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

a3c16fc17be25f6c.pngIn 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);
}

a3c16fc17be25f6c.pngAdd 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;
      }
    }
  }

a3c16fc17be25f6c.pngFinally, 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.
  }
}

a3c16fc17be25f6c.png Now that you've made the game more challenging, hot reload 7f9a9e103c7b5e5.png 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.

NooglerHat

Rocket

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);
  }
}

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

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

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

a3c16fc17be25f6c.png 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);
  }

a3c16fc17be25f6c.pngModify 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;
    }
  }

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

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

ede04fdfe074f471.gif

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

a3c16fc17be25f6c.pngModify 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;
   }

a3c16fc17be25f6c.png Restart the app and play a game to see the power ups in action.

e1fece51429dae55.gif

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.

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

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

a3c16fc17be25f6c.png Run the app on iOS or Android to see the directional buttons in action.

7b0cac5fb69bc89.gif

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: