Build a 2D Game with Flame and Flutter — Complete Project Tutorial
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
updateandrendercalls 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
Not calling super.update: Overriding
updatewithout callingsuper.update(dt)prevents child components from updating. Always callsuper.update(dt)in overridden update methods.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.Forgetting to dispose audio: FlameAudio retains loaded sounds in memory. Call
FlameAudio.bgm.dispose()andFlameAudio.audioCache.clear()when the game is disposed to prevent memory leaks.Hard-coding screen dimensions: Using fixed pixel values for positioning breaks on different screen sizes. Always reference
gameRef.size.xandgameRef.size.yfor responsive positioning.Not using RectangleHitbox: Components without hitboxes cannot participate in collision detection. Every entity that needs collision detection must have a
RectangleHitboxorCircleHitboxadded.Loading sprites synchronously:
Sprite.load()is asynchronous. If you try to render a sprite before it finishes loading, the component renders nothing. Always useawaitinonLoad.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
- Why is the
dtparameter important in theupdatemethod? - How does the component-based architecture make it easier to add new entity types (like power-ups)?
- What is the purpose of
RectangleHitboxin collision detection? - How would you implement a pause feature that freezes all game entities?
- 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
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