Building a 2D Game in Flutter with Flame: Air Hockey from Scratch

Maxim Gorin
13 min read3 days ago

--

Flutter is a powerful framework for building mobile applications, and Flame extends its capabilities by providing an easy way to create 2D games. In this tutorial, we’ll build a simple Air Hockey game from scratch using Flutter and Flame, fully compatible with DartPad. This means no setup required — just write code and play instantly.

‘Mobile Air Hockey’, generated by DALL·E

This is part of the ongoing series Dart & Flutter Essentials: Code in DartPad, No Setup Needed. The previous article was Flutter Canvas: From Basics to Interactive Drawing and Animated Equalizer.

By the end of this guide, you will:

✅ Understand how Flame’s game loop works.
✅ Learn to handle user input for moving paddles.
✅ Implement physics-based ball movement.
✅ Create collision detection with paddles and walls.
✅ Add a bot opponent that follows the ball.
✅ Display a scoreboard and a game-over screen using Flutter widgets.

How Games Work in Flame

Before we start coding, let’s understand how Flame handles game development.

  1. Game Loop — Unlike Flutter’s UI framework, which updates only when the widget tree changes, Flame runs an infinite loop that updates the game state and redraws the screen at 60 frames per second (FPS).
  2. Components — Everything in Flame is a Component. We will use PositionComponent for our ball and paddles, and TextComponent for the scoreboard.
  3. Physics & Movement — Objects in Flame move based on dt (delta time), ensuring smooth motion across different devices.
  4. User Input — Flame provides event handlers (DragCallbacks, TapCallbacks) to let users interact with the game.

Understanding these core ideas will make it easier to follow along as we build our air hockey game.

Setting Up the Game Field

Example: Setting Up the Game Field

Understanding the Game Field

In air hockey, the game field is divided into two halves, with a center line separating them. The puck (ball) moves within this field, and each player controls a paddle to hit the puck towards the opponent’s goal.

Our game field includes:

  • A center line dividing the board.
  • Two circles representing the center zone.
  • Goals at the top and bottom for scoring.

Understanding Flame’s Core Components

Now that we’ve implemented the game field, let’s break down the key elements we used and understand how they work:

FlameGame

This is the base class that represents our game. It handles the game loop, updating all components and rendering them to the screen. When we define a new class that extends FlameGame, we can override methods like onLoad() to initialize game objects.

GameWidget

Flutter apps typically use widgets to construct the UI. GameWidget is a special widget that acts as a bridge between Flutter and Flame, embedding the game inside a Flutter app and allowing it to be displayed as part of the UI.

Canvas and Paint

In Flame, rendering is done using the Canvas class, which is part of Flutter's low-level drawing API. The Paint object defines how things are drawn, including properties like color, stroke width, and style. In our example, we used Canvas to draw the field elements such as lines and circles.

PositionComponent

This is one of the most common building blocks in Flame. It represents an object that has a position, size, and rotation. The FieldComponent, Ball, and Paddle classes all extend PositionComponent, which allows us to control their placement and behavior within the game world.

Offset

Offset represents a point in 2D space. It is used in Canvas operations to determine where shapes and lines should be drawn. For example, we used Offset to place the center line and circles accurately on the field.

By understanding these elements, we gain greater control over how Flame handles rendering and game logic. These concepts will be crucial as we move forward to implement dynamic gameplay mechanics.

Code: Drawing the Field

import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
runApp(GameWidget(game: AirHockeyGame()));
}

class AirHockeyGame extends FlameGame {
@override
Future<void> onLoad() async {
add(FieldComponent());
}
}

class FieldComponent extends PositionComponent with HasGameRef<AirHockeyGame> {
@override
void render(Canvas canvas) {
final Paint paint = Paint()
..color = Colors.white
..strokeWidth = 2;

final double midX = gameRef.size.x / 2;
final double midY = gameRef.size.y / 2;

canvas.drawLine(Offset(0, midY), Offset(gameRef.size.x, midY), paint);

final double smallCircleRadius = gameRef.size.x / 24;
final double bigCircleRadius = gameRef.size.x / 8;

paint.style = PaintingStyle.fill;
canvas.drawCircle(Offset(midX, midY), smallCircleRadius, paint);

paint.style = PaintingStyle.stroke;
canvas.drawCircle(Offset(midX, midY), bigCircleRadius, paint);
}
}

Implementing Ball Movement

Example: Implementing Ball Movement

Understanding Ball Movement

In an air hockey game, the ball (puck) moves autonomously, responding to collisions with walls, paddles, and goals. Implementing smooth, realistic movement requires an understanding of several key concepts:

Velocity and Direction

The ball’s movement is determined by its velocity, which is represented as a Vector2 (x and y speed components). Each frame, the ball’s position is updated based on its velocity multiplied by dt (delta time), ensuring smooth movement across different frame rates.

Collision with Walls

When the ball reaches the edges of the game field, it must bounce back. This is done by inverting the respective velocity component when a collision is detected:

  • If the ball hits the left or right wall, invert the x-velocity.
  • If the ball hits the top or bottom wall, invert the y-velocity.

To prevent the ball from getting stuck inside the walls due to high speed, we use clamp() to ensure the ball remains within bounds after reflection.

Randomized Initial Direction

To make the game dynamic, the ball starts with a random x-direction. This prevents predictable gameplay where the ball always moves the same way at the beginning of each round.

By understanding these mechanics, we can now implement the ball’s behavior in code.

The ball moves automatically in our air hockey game. It should:

  • Start with an initial velocity.
  • Bounce off the side walls.

Code: Ball Component

import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'dart:math';

void main() {
runApp(GameWidget(game: AirHockeyGame()));
}

class AirHockeyGame extends FlameGame {
@override
Future<void> onLoad() async {
add(Ball());
}
}

class Ball extends PositionComponent with HasGameRef<AirHockeyGame> {
Vector2 velocity = Vector2(150, 150);
Random random = Random();

Ball() : super(size: Vector2(20, 20), position: Vector2(200, 300));

@override
void update(double dt) {
position += velocity * dt;

if (position.x <= 0 || position.x >= gameRef.size.x - size.x) {
velocity.x = -velocity.x;
position.x = position.x.clamp(0, gameRef.size.x - size.x);
}

if (position.y <= 0 || position.y >= gameRef.size.y - size.y) {
velocity.y = -velocity.y;
position.y = position.y.clamp(0, gameRef.size.y - size.y);
}
}

@override
void render(Canvas canvas) {
Paint paint = Paint()..color = Colors.white;
canvas.drawCircle(Offset(size.x / 2, size.y / 2), size.x / 2, paint);
}
}

Controlling the Paddle

Example: Controlling the Paddle

Understanding Paddle Control

In air hockey, players control paddles to hit the puck and prevent it from entering their goal. Implementing paddle control in Flame requires understanding a few key concepts:

Player Interaction with DragCallbacks

Flame provides the DragCallbacks mixin, which enables gesture-based interactions. Using this, we can detect when a player drags their finger across the screen and move the paddle accordingly.

Keeping the Paddle within Bounds

Since the paddle should not move beyond the game field, we need to restrict its movement within the screen’s horizontal boundaries. This is done by clamping its position so that it stays inside the play area.

Updating Paddle Position in Real-Time

When the player moves their finger, we update the paddle’s position dynamically. The movement should be smooth, so we use event.localStartPosition.x to set the new position while ensuring the paddle stays centered.

By understanding these mechanics, we can now implement paddle movement in our game.

Code: Paddle Component

import 'package:flutter/material.dart';
import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';

void main() {
runApp(GameWidget(game: AirHockeyGame()));
}

class AirHockeyGame extends FlameGame with DragCallbacks {
late Paddle _paddle;

@override
Future<void> onLoad() async {
_paddle = Paddle(Vector2(size.x / 2 - 40, size.y - 80));
add(_paddle);
}

@override
void onDragUpdate(DragUpdateEvent event) {
_paddle.position.x = event.localStartPosition.x - _paddle.size.x / 2;
}
}

class Paddle extends PositionComponent {
Paddle(Vector2 position)
: super(size: Vector2(80, 20), position: position);

@override
void render(Canvas canvas) {
Paint paint = Paint()..color = Colors.blue;
canvas.drawRect(size.toRect(), paint);
}
}

Adding Confetti Effects

To enhance the game experience, we introduce confetti effects when a player scores. This provides a visual reward, making the game more engaging.

Example: Adding Confetti Effects

Understanding Confetti Effects

Adding visual effects like confetti enhances the player experience by making the game feel more dynamic and engaging. In this section, we’ll explore how Flame’s particle system can be used to create confetti effects in our air hockey game.

What Are Particles?

Particles are small graphical elements that are generated and animated over time. They are used for effects like explosions, smoke, fire, or in our case, confetti. Each particle has:

  • A lifespan (how long it lasts before disappearing).
  • An initial position and velocity (determining how it moves across the screen).
  • Acceleration or gravity (to simulate realistic motion).

Using the Particle System in Flame

Flame provides the ParticleComponent class, which allows us to create custom particle effects. We can specify different behaviors, such as:

  • Randomized movement in different directions.
  • Color variation for a vibrant confetti effect.
  • Gradual fading or shrinking to create a smooth animation.

Triggering Confetti on User Input

Initially, we spawn a confetti burst when the game starts. However, to make it more interactive, we add an event listener that triggers additional confetti bursts when the player taps the screen. This creates a satisfying feedback loop, reinforcing user engagement.

Now that we understand how confetti effects work, let’s implement them in our game.

Code: Confetti Effect

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'dart:math';

void main() {
runApp(GameWidget(game: AirHockeyGame()));
}

class AirHockeyGame extends FlameGame with TapCallbacks {
@override
Future<void> onLoad() async {
add(Confetti());
}

@override
void onTapDown(TapDownEvent event) {
add(Confetti());
}
}

class Confetti extends Component with HasGameRef<AirHockeyGame> {
final List<ConfettiParticle> _particles = [];
final Random _random = Random();
int _frames = 100;

Confetti();

@override
Future<void> onLoad() async {
double width = gameRef.size.x;
double height = gameRef.size.y;

for (int i = 0; i < 100; i++) {
Color color = Colors.primaries[_random.nextInt(Colors.primaries.length)];
_particles.add(ConfettiParticle(
Offset(_random.nextDouble() * width, _random.nextDouble() * height / 2),
color,
_random.nextDouble() * 3 + 2,
));
}
}

@override
void render(Canvas canvas) {
for (var particle in _particles) {
particle.render(canvas);
}
}

@override
void update(double dt) {
for (var particle in _particles) {
particle.update(dt);
}

if (_frames-- <= 0) removeFromParent();
}
}

class ConfettiParticle {
Offset position;
final Color color;
final double speed;
final Paint paint = Paint();

ConfettiParticle(this.position, this.color, this.speed) {
paint.color = color;
}

void render(Canvas canvas) {
canvas.drawCircle(position, 5, paint);
}

void update(double dt) {
position = Offset(position.dx, position.dy + speed);
}
}

Implementing the Overlay Screen

Example: Implementing the Overlay Screen

Understanding Game Over Screens

An overlay screen is essential in game development for displaying important UI elements like scoreboards, menus, and game-over messages. In our air hockey game, we’ll implement a Game Over screen that appears when a player scores a goal. The overlay will include:

  • A message indicating the game is over.
  • A “Try Again” button that restarts the game.
  • A tap gesture to dismiss the overlay and continue playing.

Code: Overlay Implementation

import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
runApp(MaterialApp(
home: Scaffold(
body: GameWidget(
game: AirHockeyGame(),
overlayBuilderMap: {
'GameOver': (context, AirHockeyGame game) => GameOverOverlay(game),
'HintOverlay': (context, AirHockeyGame game) => HintOverlay(),
},
),
),
));
}

class AirHockeyGame extends FlameGame with TapCallbacks {
@override
Future<void> onLoad() async {
overlays.add('HintOverlay');
}

@override
void onTapDown(TapDownEvent event) {
overlays.add('GameOver');
overlays.remove('HintOverlay');
}

void restartGame() {
overlays.add('HintOverlay');
overlays.remove('GameOver');
}
}

class GameOverOverlay extends StatelessWidget {
final AirHockeyGame game;
const GameOverOverlay(this.game, {super.key});

@override
Widget build(BuildContext context) {
final String text = "You Win!";
final Color color = Colors.yellow;

return Stack(
children: [
Container(color: Colors.white),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
style: TextStyle(fontSize: 48, color: color),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: game.restartGame,
child: const Text(
"Try Again",
style: const TextStyle(fontSize: 24),
),
),
],
),
),
],
);
}
}

class HintOverlay extends StatelessWidget {
@override
Widget build(BuildContext context) {
final String text = "Tap Here!";
final Color color = Colors.white;

return Stack(
children: [
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
style: TextStyle(fontSize: 48, color: color),
),
],
),
),
],
);
}
}

Full Game Code

Example: Full Game

We have explored each aspect of our air hockey game step by step. Now, let’s put everything together into a single, complete implementation:

import 'package:flutter/material.dart';
import 'package:flame/game.dart';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'dart:math';

void main() {
runApp(MaterialApp(
home: Scaffold(
body: GameWidget(
game: AirHockeyGame(),
overlayBuilderMap: {
'GameOver': (context, AirHockeyGame game) => GameOverOverlay(game),
},
),
),
));
}

class AirHockeyGame extends FlameGame with DragCallbacks {
late PlayerPaddle player;
late BotPaddle bot;
late Ball ball;
late Goal playerGoal;
late Goal opponentGoal;

late ScoreBoard _scoreBoard;
late FieldLines _fieldLines;

bool gameOver = false;
bool playerWon = false;

int _playerScore = 0;
int _opponentScore = 0;
final int _maxScore = 3;

@override
Future<void> onLoad() async {
player = PlayerPaddle(Vector2(size.x / 2 - 40, size.y - 80));
bot = BotPaddle(Vector2(size.x / 2 - 40, 40));
ball = Ball();
playerGoal = Goal(isPlayerGoal: true);
opponentGoal = Goal(isPlayerGoal: false);

_fieldLines = FieldLines();
_scoreBoard = ScoreBoard();

addAll([player, bot, ball, playerGoal, opponentGoal, _fieldLines, _scoreBoard]);
}

@override
void onDragUpdate(DragUpdateEvent event) {
if (!gameOver) {
player.position.x = event.localStartPosition.x - player.size.x / 2;
}
}

void increaseScore(bool playerScored) {
if (gameOver) return;
if (playerScored) {
_playerScore++;
} else {
_opponentScore++;
}

_scoreBoard.updateScore(_playerScore, _opponentScore);
if (_playerScore >= _maxScore || _opponentScore >= _maxScore) {
gameOver = true;
playerWon = _playerScore > _opponentScore;
overlays.add('GameOver');
ball.removeFromParent();
bot.active = false;
} else {
add(Confetti(playerScored));
ball.reset();
}
}

void restartGame() {
_playerScore = 0;
_opponentScore = 0;
gameOver = false;
bot.active = true;
add(ball = Ball());
_scoreBoard.updateScore(_playerScore, _opponentScore);
overlays.remove('GameOver');
}
}

class Ball extends PositionComponent with HasGameRef<AirHockeyGame> {
Vector2 velocity = Vector2(150, 150);
Random random = Random();

Ball() : super(size: Vector2(20, 20), position: Vector2(200, 300));

@override
void update(double dt) {
if (gameRef.gameOver) return;
position += velocity * dt;

if (position.x <= 0 || position.x >= gameRef.size.x - size.x) {
velocity.x = -velocity.x;
position.x = position.x.clamp(0, gameRef.size.x - size.x);
}

if (position.y <= 0 || position.y >= gameRef.size.y - size.y) {
velocity.y = -velocity.y;
position.y = position.y.clamp(0, gameRef.size.y - size.y);
}

if (collidesWith(gameRef.player)) velocity.y = -velocity.y.abs();
if (collidesWith(gameRef.bot)) velocity.y = velocity.y.abs();
if (collidesWith(gameRef.playerGoal)) gameRef.increaseScore(false);
if (collidesWith(gameRef.opponentGoal)) gameRef.increaseScore(true);
}

void reset() {
position = Vector2(200, 300);
velocity = Vector2((random.nextBool() ? 1 : -1) * 150, (random.nextBool() ? 1 : -1) * 150);
}

bool collidesWith(PositionComponent component) {
return component.toRect().overlaps(toRect());
}

@override
void render(Canvas canvas) {
Paint paint = Paint()..color = Colors.white;
canvas.drawCircle(Offset(size.x / 2, size.y / 2), size.x / 2, paint);
}
}

class Goal extends PositionComponent with HasGameRef<AirHockeyGame> {
final bool isPlayerGoal;

Goal({required this.isPlayerGoal}): super();

@override
void render(Canvas canvas) {
final Paint paint = Paint()..color = Colors.green;
final double posY = isPlayerGoal ? gameRef.size.y - 10 : 0;
size = Vector2(gameRef.size.x / 4, 10);
position = Vector2(gameRef.size.x / 2 - size.x / 2, posY);
canvas.drawRect(size.toRect(), paint);
}
}

class PlayerPaddle extends PositionComponent {
PlayerPaddle(Vector2 position)
: super(size: Vector2(80, 20), position: position);

@override
void render(Canvas canvas) {
Paint paint = Paint()..color = Colors.blue;
canvas.drawRect(size.toRect(), paint);
}
}

class BotPaddle extends PositionComponent with HasGameRef<AirHockeyGame> {
BotPaddle(Vector2 position)
: super(size: Vector2(80, 20), position: position);

bool active = true;

@override
void update(double dt) {
if (!active) return;
if (gameRef.ball.position.x > position.x) {
position.x += 100 * dt;
} else {
position.x -= 100 * dt;
}
}

@override
void render(Canvas canvas) {
Paint paint = Paint()..color = Colors.red;
canvas.drawRect(size.toRect(), paint);
}
}

class ScoreBoard extends PositionComponent with HasGameRef<AirHockeyGame> {
TextPaint textPaint = TextPaint(style: const TextStyle(fontSize: 32, color: Colors.white));
int playerScore = 0;
int opponentScore = 0;

ScoreBoard() : super(position: Vector2(10, 10));

void updateScore(int player, int opponent) {
playerScore = player;
opponentScore = opponent;
}

@override
void render(Canvas canvas) {
textPaint.render(canvas, "$playerScore : $opponentScore", Vector2(20, 0));
}
}

class FieldLines extends Component with HasGameRef<AirHockeyGame> {
@override
void render(Canvas canvas) {
Paint paint = Paint()
..color = Colors.white
..strokeWidth = 2;
final double midX = gameRef.size.x / 2;
final double midY = gameRef.size.y / 2;

canvas.drawLine(Offset(0, midY), Offset(gameRef.size.x, midY), paint);

final double smallCircleRadius = gameRef.size.x / 24;
final double bigCircleRadius = gameRef.size.x / 8;

paint.style = PaintingStyle.fill;
canvas.drawCircle(Offset(midX, midY), smallCircleRadius, paint);

paint.style = PaintingStyle.stroke;
canvas.drawCircle(Offset(midX, midY), bigCircleRadius, paint);
}
}

class Confetti extends Component with HasGameRef<AirHockeyGame> {
final List<ConfettiParticle> _particles = [];
final Random _random = Random();
int _frames = 100;
final bool isWin;

Confetti(this.isWin);

@override
Future<void> onLoad() async {
double width = gameRef.size.x;
double height = gameRef.size.y;
for (int i = 0; i < 100; i++) {
Color color = isWin
? Colors.primaries[_random.nextInt(Colors.primaries.length)]
: Colors.red.withValues(alpha: _random.nextDouble() * 0.5 + 0.5);
_particles.add(ConfettiParticle(
Offset(_random.nextDouble() * width, _random.nextDouble() * height / 2),
color,
_random.nextDouble() * 3 + 2,
));
}
}

@override
void render(Canvas canvas) {
for (var particle in _particles) {
particle.render(canvas);
}
}

@override
void update(double dt) {
for (var particle in _particles) {
particle.update(dt);
}
if (_frames-- <= 0) removeFromParent();
}
}

class ConfettiParticle {
Offset position;
final Color color;
final double speed;
final Paint paint = Paint();

ConfettiParticle(this.position, this.color, this.speed) {
paint.color = color;
}

void render(Canvas canvas) {
canvas.drawCircle(position, 5, paint);
}

void update(double dt) {
position = Offset(position.dx, position.dy + speed);
}
}


class GameOverOverlay extends StatelessWidget {
final AirHockeyGame game;
const GameOverOverlay(this.game, {super.key});

@override
Widget build(BuildContext context) {
final String text = game.playerWon ? "You Win!" : "You Lose!";
final Color color = game.playerWon ? Colors.yellow : Colors.red;

return Stack(
children: [
Container(color: Colors.white),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
style: TextStyle(fontSize: 48, color: color),
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: game.restartGame,
child: const Text(
"Try Again",
style: const TextStyle(fontSize: 24),
),
),
],
),
),
],
);
}
}

Conclusion

Congratulations! 🎉 You’ve built a fully functional Air Hockey game using Flutter and Flame. Along the way, we’ve covered:

  • Setting up the game field.
  • Implementing ball movement and physics.
  • Adding paddle controls using gestures.
  • Creating confetti effects for interactivity.
  • Implementing an overlay screen for game-over events.

You’ve made it to the finish line! But this is just the beginning. Game development is all about creativity and innovation — so take what you’ve learned and build upon it.

If you found this guide helpful, don’t forget to follow, share, and leave a comment below! Your feedback and ideas drive this community forward, and I’d love to hear about your projects. 🚀

--

--

Maxim Gorin
Maxim Gorin

Written by Maxim Gorin

Team lead in mobile development with a passion for Fintech and Flutter. Sharing insights and stories from the tech and dev world on this blog.

No responses yet