Skip to content

Build a 2D Game with Flame and Flutter — Complete Project Tutorial

DodaTech Updated 2026-06-28 12 min read

In this tutorial, you will learn about Build a 2D Game with Flame and Flutter. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete 2D game using the Flame game engine in Flutter with Dart, implementing sprite rendering, collision detection, keyboard and touch input, score tracking, and a game loop that runs at 60 frames per second.

What You Will Learn

  • Setting up the Flame engine in a Flutter project
  • Creating game components (sprites, text, audio)
  • Implementing the game loop with update and render methods
  • Handling keyboard and touch input for player movement
  • Detecting collisions between game objects
  • Managing game state (menu, playing, game over)
  • Adding audio effects and background music
  • Building a scoring system with persistent high scores

Why It Matters

Game Development teaches fundamental programming concepts that apply to all software: the game loop teaches Event-Driven Architecture, collision detection teaches spatial reasoning and math, sprite management teaches resource lifecycle, and input handling teaches event propagation. These patterns appear in non-game applications too — animation frameworks, real-time dashboards, and interactive data visualizations all use variations of the game loop pattern. Doda Browser's tab switching animation uses a simplified version of the sprite transformation and tweening techniques covered in this tutorial.

Real-World Use

A security training app uses Flame to build interactive phishing recognition games. Players navigate a character through a virtual office, clicking on phishing emails while avoiding legitimate ones. The game teaches security awareness in an engaging format that increases retention compared to traditional training videos. The Flame engine powers the game logic, sprite rendering, and scoring system described in this project.

Learning Path

flowchart LR
  A[Project E-Commerce UI] --> B[Project Game with Flame\nYou are here]
  B --> C[Dart Ecosystem]
  style B fill:#f90,color:#fff

Project Setup

Create a new Flutter project:

flutter create space_shooter
cd space_shooter

Add the Flame dependencies:

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.14.0
  flame_audio: ^2.1.0
  shared_preferences: ^2.2.0

flame provides the core game engine. flame_audio adds sound effect and music support. shared_preferences persists the high score.

Understanding the Flame Architecture

Flame uses a component-based architecture built on top of Flutter's canvas system. The key concepts are:

  • Game: The top-level class that manages the game loop. It receives update and render calls at a target of 60 FPS.
  • Component: Visual or logical elements (sprites, text, shapes) attached to the game. Components can have children, forming a scene graph.
  • World: Handles camera positioning and parallax scrolling. Optional for simple games.
  • Input: Keyboard, mouse, touch, and gamepad input are available through mixins.

The game loop runs continuously: update(dt) calculates physics, checks collisions, and updates positions. render(Canvas) draws everything to the screen.

Creating the Game Class

Start with the main game class:

// lib/game/space_shooter_game.dart
import 'dart:math';
import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/input.dart';
import 'package:flame/collisions.dart';
import 'package:flame_audio/flame_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';

class SpaceShooterGame extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
  late final Player _player;
  final List<Enemy> _enemies = [];
  final List<Bullet> _bullets = [];
  final Random _random = Random();
  int _score = 0;
  int _highScore = 0;
  double _spawnTimer = 0;

  @override
  Future<void> onLoad() async {
    await FlameAudio.audioCache.load('shoot.wav');
    await FlameAudio.audioCache.load('explosion.wav');
    await FlameAudio.audioCache.load('bgm.mp3');

    await _loadHighScore();

    _player = Player();
    add(_player);

    // Start background music
    FlameAudio.bgm.play('bgm.mp3', volume: 0.3);
  }

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

    _spawnTimer += dt;
    if (_spawnTimer > 1.5) {
      _spawnTimer = 0;
      _spawnEnemy();
    }

    _checkCollisions();
  }

  void _spawnEnemy() {
    final x = _random.nextDouble() * size.x;
    final enemy = Enemy(Vector2(x, -50));
    _enemies.add(enemy);
    add(enemy);
  }

  void _checkCollisions() {
    for (final bullet in _bullets.toList()) {
      for (final enemy in _enemies.toList()) {
        if (bullet.toRect().overlaps(enemy.toRect())) {
          _destroyEnemy(enemy);
          _removeBullet(bullet);
          _score += 10;
          break;
        }
      }
    }

    for (final enemy in _enemies.toList()) {
      if (enemy.position.y > size.y + 50) {
        _removeEnemy(enemy);
      }
      if (enemy.toRect().overlaps(_player.toRect())) {
        _gameOver();
      }
    }
  }

  void _destroyEnemy(Enemy enemy) {
    _enemies.remove(enemy);
    remove(enemy);
    FlameAudio.play('explosion.wav');
  }

  void _removeEnemy(Enemy enemy) {
    _enemies.remove(enemy);
    remove(enemy);
  }

  void fireBullet(Vector2 position) {
    final bullet = Bullet(position);
    _bullets.add(bullet);
    add(bullet);
    FlameAudio.play('shoot.wav');
  }

  void _removeBullet(Bullet bullet) {
    _bullets.remove(bullet);
    remove(bullet);
  }

  void _gameOver() {
    if (_score > _highScore) {
      _highScore = _score;
      _saveHighScore();
    }
    FlameAudio.bgm.stop();
    // Transition to game over overlay
    overlays.add('gameOver');
    pauseEngine();
  }

  Future<void> _loadHighScore() async {
    final prefs = await SharedPreferences.getInstance();
    _highScore = prefs.getInt('highScore') ?? 0;
  }

  Future<void> _saveHighScore() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt('highScore', _highScore);
  }

  void reset() {
    _score = 0;
    _enemies.clear();
    _bullets.clear();
    _player.position = Vector2(size.x / 2, size.y - 100);
    FlameAudio.bgm.play('bgm.mp3', volume: 0.3);
    resumeEngine();
  }

  int get score => _score;
  int get highScore => _highScore;
}

The game class manages all entities. It spawns enemies every 1.5 seconds, checks collisions between bullets and enemies, and triggers game over when the player touches an enemy.

Player Component

The player ship responds to keyboard input:

// lib/game/player.dart
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/collisions.dart';
import 'dart:ui';

class Player extends SpriteComponent
    with HasGameRef, KeyboardHandler {
  final double _speed = 300.0;
  bool _leftPressed = false;
  bool _rightPressed = false;
  bool _upPressed = false;
  bool _downPressed = false;
  double _fireTimer = 0;

  @override
  Future<void> onLoad() async {
    sprite = await Sprite.load('player.png');
    size = Vector2(64, 64);
    position = Vector2(gameRef.size.x / 2, gameRef.size.y - 100);
    anchor = Anchor.center;
    add(RectangleHitbox());
  }

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

    final direction = Vector2(0, 0);
    if (_leftPressed) direction.x -= 1;
    if (_rightPressed) direction.x += 1;
    if (_upPressed) direction.y -= 1;
    if (_downPressed) direction.y += 1;

    if (direction.length > 0) {
      direction.normalize();
      position += direction * _speed * dt;
    }

    // Clamp to screen bounds
    position.x = position.x.clamp(size.x / 2, gameRef.size.x - size.x / 2);
    position.y = position.y.clamp(size.y / 2, gameRef.size.y - size.y / 2);

    // Auto-fire
    _fireTimer += dt;
    if (_fireTimer > 0.3) {
      _fireTimer = 0;
      final spaceGame = gameRef as dynamic;
      spaceGame.fireBullet(Vector2(position.x, position.y - size.y / 2));
    }
  }

  @override
  bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    _leftPressed = keysPressed.contains(LogicalKeyboardKey.arrowLeft) ||
        keysPressed.contains(LogicalKeyboardKey.keyA);
    _rightPressed = keysPressed.contains(LogicalKeyboardKey.arrowRight) ||
        keysPressed.contains(LogicalKeyboardKey.keyD);
    _upPressed = keysPressed.contains(LogicalKeyboardKey.arrowUp) ||
        keysPressed.contains(LogicalKeyboardKey.keyW);
    _downPressed = keysPressed.contains(LogicalKeyboardKey.arrowDown) ||
        keysPressed.contains(LogicalKeyboardKey.keyS);

    if (keysPressed.contains(LogicalKeyboardKey.space)) {
      final spaceGame = gameRef as dynamic;
      spaceGame.fireBullet(Vector2(position.x, position.y - size.y / 2));
    }

    return true;
  }
}

The player uses a SpriteComponent with a loaded image, moves with arrow keys or WASD, automatically fires bullets every 0.3 seconds, and is clamped to the screen bounds.

Enemy Component

Enemies move downward and can be destroyed:

// lib/game/enemy.dart
import 'package:flame/components.dart';
import 'package:flame/collisions.dart';

class Enemy extends SpriteComponent with HasGameRef {
  final double _speed = 150.0;

  Enemy(Vector2 position) {
    this.position = position;
  }

  @override
  Future<void> onLoad() async {
    sprite = await Sprite.load('enemy.png');
    size = Vector2(48, 48);
    anchor = Anchor.center;
    add(RectangleHitbox());
  }

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

Enemies spawn at random x positions above the screen and move downward at 150 pixels per second.

Bullet Component

Bullets travel upward from the player:

// lib/game/bullet.dart
import 'package:flame/components.dart';
import 'package:flame/collisions.dart';

class Bullet extends SpriteComponent with HasGameRef {
  final double _speed = 500.0;

  Bullet(Vector2 position) {
    this.position = position;
  }

  @override
  Future<void> onLoad() async {
    sprite = await Sprite.load('bullet.png');
    size = Vector2(8, 16);
    anchor = Anchor.center;
    add(RectangleHitbox());
  }

  @override
  void update(double dt) {
    super.update(dt);
    position.y -= _speed * dt;

    // Remove if off screen
    if (position.y < -size.y) {
      removeFromParent();
    }
  }
}

HUD and Overlay

Add a heads-up display showing the score:

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

class Hud extends Component with HasGameRef {
  late TextComponent _scoreText;
  late TextComponent _highScoreText;

  @override
  Future<void> onLoad() async {
    _scoreText = TextComponent(
      text: 'Score: 0',
      position: Vector2(16, 16),
      textRenderer: TextPaint(
        style: TextStyle(color: const Color(0xFFFFFFFF), fontSize: 24, fontFamily: 'monospace'),
      ),
    );
    add(_scoreText);

    _highScoreText = TextComponent(
      text: 'Best: 0',
      position: Vector2(16, 48),
      textRenderer: TextPaint(
        style: TextStyle(color: const Color(0xFFFFFFFF), fontSize: 18, fontFamily: 'monospace'),
      ),
    );
    add(_highScoreText);
  }

  @override
  void update(double dt) {
    super.update(dt);
    final spaceGame = gameRef as dynamic;
    _scoreText.text = 'Score: ${spaceGame.score}';
    _highScoreText.text = 'Best: ${spaceGame.highScore}';
  }
}

Game over overlay managed by the Flutter widget:

// lib/widgets/game_over_overlay.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../game/space_shooter_game.dart';

class GameOverOverlay extends StatelessWidget {
  final SpaceShooterGame game;

  const GameOverOverlay({required this.game});

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colors.black54,
      child: Center(
        child: Card(
          margin: EdgeInsets.all(32),
          child: Padding(
            padding: EdgeInsets.all(32),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text('Game Over', style: Theme.of(context).textTheme.headlineMedium),
                SizedBox(height: 16),
                Text('Score: ${game.score}', style: Theme.of(context).textTheme.titleLarge),
                Text('Best: ${game.highScore}', style: Theme.of(context).textTheme.titleMedium),
                SizedBox(height: 24),
                FilledButton(
                  onPressed: () {
                    game.reset();
                    game.overlays.remove('gameOver');
                  },
                  child: Text('Play Again'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Main Flutter Entry Point

Wire everything together in main.dart:

// lib/main.dart
import 'package:flutter/material.dart';
import 'game/space_shooter_game.dart';
import 'game/hud.dart';
import 'widgets/game_over_overlay.dart';

void main() {
  runApp(SpaceShooterApp());
}

class SpaceShooterApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Space Shooter',
      theme: ThemeData.dark(),
      home: SpaceShooterScreen(),
    );
  }
}

class SpaceShooterScreen extends StatefulWidget {
  @override
  State<SpaceShooterScreen> createState() => _SpaceShooterScreenState();
}

class _SpaceShooterScreenState extends State<SpaceShooterScreen> {
  final game = SpaceShooterGame();

  @override
  void initState() {
    super.initState();
    game.add(Hud());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: [
          GameWidget(
            game: game,
            overlayBuilderMap: {
              'gameOver': (context, game) => GameOverOverlay(game: game as SpaceShooterGame),
            },
          ),
        ],
      ),
    );
  }
}

Game Assets

Place these assets in the assets/ directory and register them in pubspec.yaml:

assets/
  images/
    player.png
    enemy.png
    bullet.png
  audio/
    shoot.wav
    explosion.wav
    bgm.mp3
flutter:
  assets:
    - assets/images/
    - assets/audio/

For development, create simple colored rectangles as placeholder sprites using a Dart script or use placeholder PNG files. The rectangles approach works for testing before final art is available.

Adding Touch Controls for Mobile

Since desktop keyboards are not available on mobile, add touch controls:

// lib/game/touch_controls.dart
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'dart:ui';

class TouchControls extends Component with HasGameRef, TapCallbacks, DragCallbacks {
  double? _dragStartX;

  @override
  bool onTapUp(TapUpEvent event) {
    final spaceGame = gameRef as dynamic;
    spaceGame.fireBullet(Vector2(event.localPosition.x, event.localPosition.y - 20));
    return true;
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    final player = gameRef.children.whereType<Player>().firstOrNull;
    if (player != null) {
      player.position.x += event.localDelta.x;
      player.position.y += event.localDelta.y;
    }
  }
}

Add the touch controls component in the game's onLoad:

add(TouchControls());

Adding Power-Ups and Advanced Features

Extend the game with collectible power-ups:

// lib/game/power_up.dart
import 'package:flame/components.dart';
import 'package:flame/collisions.dart';
import 'dart:math';

enum PowerUpType { shield, rapidFire, tripleShot }

class PowerUp extends SpriteComponent with HasGameRef {
  final PowerUpType type;
  PowerUp(this.type, Vector2 position) {
    this.position = position;
  }

  @override
  Future<void> onLoad() async {
    size = Vector2(32, 32);
    anchor = Anchor.center;
    add(RectangleHitbox());
  }

  @override
  void update(double dt) {
    super.update(dt);
    position.y += 100 * dt;
    if (position.y > gameRef.size.y + 50) {
      removeFromParent();
    }
  }
}

Spawn power-ups randomly:

// In SpaceShooterGame.update
_powerUpTimer += dt;
if (_powerUpTimer > 10) {
  _powerUpTimer = 0;
  final types = PowerUpType.values;
  final type = types[_random.nextInt(types.length)];
  final x = _random.nextDouble() * size.x;
  add(PowerUp(type, Vector2(x, -32)));
}

Common Mistakes

  1. Not calling super.update: Overriding update without calling super.update(dt) prevents child components from updating. Always call super.update(dt) in overridden update methods.

  2. Removing components during iteration: Modifying a list while iterating over it causes concurrent modification errors. Use toList() to create a copy before iteration, then remove from the original.

  3. Forgetting to dispose audio: FlameAudio retains loaded sounds in memory. Call FlameAudio.bgm.dispose() and FlameAudio.audioCache.clear() when the game is disposed to prevent memory leaks.

  4. Hard-coding screen dimensions: Using fixed pixel values for positioning breaks on different screen sizes. Always reference gameRef.size.x and gameRef.size.y for responsive positioning.

  5. Not using RectangleHitbox: Components without hitboxes cannot participate in collision detection. Every entity that needs collision detection must have a RectangleHitbox or CircleHitbox added.

  6. Loading sprites synchronously: Sprite.load() is asynchronous. If you try to render a sprite before it finishes loading, the component renders nothing. Always use await in onLoad.

  7. Ignoring the game loop delta time: Movement speeds must be multiplied by dt (delta time) to be frame-rate independent. Without this, the game runs faster on high-refresh-rate displays and slower on low-end devices.

Practice Questions

  1. Why is the dt parameter important in the update method?
  2. How does the component-based architecture make it easier to add new entity types (like power-ups)?
  3. What is the purpose of RectangleHitbox in collision detection?
  4. How would you implement a pause feature that freezes all game entities?
  5. Challenge: Add a boss enemy that appears every 500 points. The boss has 10 hit points, moves in a sine wave pattern, fires its own bullets at the player, and spawns smaller enemies when destroyed. Reward the player with 200 points for defeating it.

Mini Project

Build a complete game with these features:

  • Start screen with title, high score display, and "Play" button
  • Main game with player movement, enemy spawning, bullet firing, and collision
  • Power-ups: shield (absorbs one hit), rapid fire (doubles fire rate for 5 seconds), triple shot (fires three bullets at once)
  • Enemy types: basic (moves straight down), zigzag (sine wave movement), fast (double speed, half size)
  • Level system: speed and spawn rate increase every 1000 points
  • Sound effects for shooting, explosions, power-up collection, and game over
  • High score persistence across sessions
  • Particle effects for explosions using Flame's ParticleSystemComponent

FAQ

Can I use Flame for non-game applications?

Yes. Flame's component system, game loop, and animation tools work well for interactive dashboards, custom animations, drawing apps, and any application that needs a 60 FPS rendering loop.

How do I add more levels?

Create a Level class that defines spawn rates, enemy types, and background color. Store levels in a list and increment the level index when the score threshold is reached. The game's update method reads the current level's parameters.

Does Flame support multiplayer?

Flame does not include built-in networking. For multiplayer, use a separate networking library (like web_socket_channel) to sync game state between clients, and use Flame for rendering on each client.

How do I optimize performance for older devices?

Reduce the number of simultaneous sprites, use sprite atlases instead of individual images, limit particle effects, and reduce the update frequency for off-screen entities. Profile with Flutter DevTools to identify bottlenecks.

What are the alternatives to Flame?

Bonfire (built on Flame, for RPGs), SpriteWidget (lighter weight), and raw CustomPainter (maximum control). Flame is the most popular choice with the largest community and best documentation for 2D games.

What is Next

Congratulations on completing all 40 Dart lessons! Next, explore the broader Dart Ecosystem to learn about server-side Dart with Shelf, command-line tools, and advanced language features. You can also start the Swift Course or Kotlin Course to apply the same patterns on different platforms.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro