Flutter Tutorial

Flutter Intermediate Lesson 113 of 135 3 min read

Overlays in Flame: Main Menu, Pause and Game Over with Flutter Widgets

Put Flutter widgets on top of a Flame game with overlays: main menu, pause button, game over screen and restart, in a complete runnable game.

On this page

Menus, buttons, dialogs and scores are easier to build with normal Flutter widgets than inside the game. Flame’s overlays let you show any widget on top of the game and turn it on and off from game code.

How overlays work

  1. Give GameWidget an overlayBuilderMap: names mapped to widget builders.
  2. Show one with game.overlays.add('Name'), hide it with game.overlays.remove('Name').
  3. Use initialActiveOverlays for the ones shown at the start (like the main menu).

A complete game with menus

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

void main() {
  runApp(MaterialApp(
    debugShowCheckedModeBanner: false,
    home: Scaffold(
      body: GameWidget<TapGame>(
        game: TapGame(),
        overlayBuilderMap: {
          'MainMenu': (context, game) => MainMenu(game: game),
          'Hud': (context, game) => Hud(game: game),
          'GameOver': (context, game) => GameOverMenu(game: game),
        },
        initialActiveOverlays: const ['MainMenu'],
      ),
    ),
  ));
}

class TapGame extends FlameGame {
  final random = Random();
  final score = ValueNotifier<int>(0); // the HUD listens to this
  double timeLeft = 15;

  @override
  Future<void> onLoad() async {
    camera.viewfinder.anchor = Anchor.topLeft;
    pauseEngine(); // wait on the main menu
  }

  void start() {
    score.value = 0;
    timeLeft = 15;
    world.removeAll(world.children.toList());
    world.add(Target());
    overlays
      ..remove('MainMenu')
      ..remove('GameOver')
      ..add('Hud');
    resumeEngine();
  }

  @override
  void update(double dt) {
    super.update(dt);
    timeLeft -= dt;
    if (timeLeft <= 0) {
      pauseEngine();
      overlays
        ..remove('Hud')
        ..add('GameOver');
    }
  }
}

class Target extends CircleComponent with TapCallbacks, HasGameReference<TapGame> {
  Target() : super(radius: 35, anchor: Anchor.center, paint: Paint()..color = Colors.redAccent);

  @override
  void onMount() {
    super.onMount();
    jump();
  }

  void jump() {
    position = Vector2(
      radius + game.random.nextDouble() * (game.size.x - radius * 2),
      100 + game.random.nextDouble() * (game.size.y - 200),
    );
  }

  @override
  void onTapDown(TapDownEvent event) {
    game.score.value++;
    jump();
  }
}

// ---------- Flutter overlays ----------

class MainMenu extends StatelessWidget {
  const MainMenu({super.key, required this.game});
  final TapGame game;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(32),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Text('Tap the Target', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
              const SizedBox(height: 8),
              const Text('Hit the red circle as many times as you can in 15 seconds.'),
              const SizedBox(height: 24),
              FilledButton.icon(
                onPressed: game.start,
                icon: const Icon(Icons.play_arrow),
                label: const Text('Play'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class Hud extends StatelessWidget {
  const Hud({super.key, required this.game});
  final TapGame game;

  @override
  Widget build(BuildContext context) {
    return SafeArea(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Row(
          children: [
            ValueListenableBuilder<int>(
              valueListenable: game.score,
              builder: (context, score, _) => Text(
                'Score: $score',
                style: const TextStyle(color: Colors.white, fontSize: 22),
              ),
            ),
            const Spacer(),
            StatefulBuilder(
              builder: (context, setState) => IconButton.filledTonal(
                icon: Icon(game.paused ? Icons.play_arrow : Icons.pause),
                onPressed: () {
                  game.paused ? game.resumeEngine() : game.pauseEngine();
                  setState(() {}); // refresh the icon
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class GameOverMenu extends StatelessWidget {
  const GameOverMenu({super.key, required this.game});
  final TapGame game;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(32),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              const Text('Time up!', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
              const SizedBox(height: 8),
              Text('You scored ${game.score.value}', style: const TextStyle(fontSize: 20)),
              const SizedBox(height: 24),
              FilledButton(onPressed: game.start, child: const Text('Play again')),
            ],
          ),
        ),
      ),
    );
  }
}

Talking between the game and the widgets

  • Widget → game: overlays receive the game, so a button can call game.start() or game.pauseEngine().
  • Game → widget: put values the UI shows in a ValueNotifier (like score) and use a ValueListenableBuilder. For bigger apps, a state management package such as Riverpod or Bloc works too; Flame has flame_riverpod and flame_bloc packages.

Tips

  • Keep the HUD as an overlay or as TextComponents in the camera viewport; overlays are best when you need buttons.
  • Pause the engine while a menu is open so nothing moves behind it.
  • Overlays are normal widgets: use animations, themes and fonts as in any app.

Try it yourself

Add a “Settings” overlay reachable from the main menu, with a slider that changes the round length from 10 to 60 seconds.

Practise in the playground Updated