Flutter non serve solo per app gestionali ed e-commerce: il suo motore di rendering è perfettamente in grado di disegnare centinaia di sprite a 60 fps. Flame è il motore di gioco 2D open source costruito sopra Flutter che aggiunge ciò che manca: un game loop, un sistema a componenti, gestione di sprite e animazioni, rilevamento delle collisioni, camera, effetti e audio.

In questa guida costruiamo le fondamenta di uno shooter verticale, vedendo i concetti chiave che servono in qualsiasi progetto Flame.

Perché Flame e quando (non) usarlo

Flame è la scelta giusta per:

  • giochi 2D casual, puzzle, platform, endless runner;
  • mini-giochi integrati in un'app esistente (gamification, onboarding animato);
  • simulazioni e visualizzazioni interattive con molti elementi in movimento.

Non è la scelta giusta per il 3D (meglio Unity/Godot o flutter_scene sperimentale) né per UI classiche: per quelle i widget Flutter restano imbattibili. La buona notizia è che le due cose convivono: un GameWidget è un widget come gli altri e può stare in uno Stack con menu, pulsanti e dialog costruiti in Flutter.

Setup

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.23.0
  flame_audio: ^2.10.0

flutter:
  assets:
    - assets/images/
    - assets/audio/

Flame cerca di default le immagini in assets/images/ e gli audio in assets/audio/: rispettare questa convenzione evita configurazioni extra.

Il punto di ingresso è il GameWidget:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Flame.device.fullScreen();
  await Flame.device.setPortrait();
  runApp(GameWidget(game: SpaceGame()));
}

Il Flame Component System

Il cuore di Flame è il FCS (Flame Component System): tutto ciò che vive nel gioco è un Component organizzato in un albero, esattamente come i widget. Ogni componente ha due metodi fondamentali chiamati dal game loop:

  • update(double dt): aggiorna lo stato logico, dove dt è il tempo in secondi trascorso dall'ultimo frame;
  • render(Canvas canvas): disegna sul canvas (spesso non serve sovrascriverlo, lo fanno già i componenti pronti).

Usare sempre dt per i movimenti è la regola d'oro: position.y += 200 * dt significa "200 pixel al secondo" indipendentemente dal frame rate, mentre position.y += 3 produce velocità diverse su dispositivi diversi.

La classe base del gioco è FlameGame, che espone due oggetti chiave: world (il mondo di gioco, soggetto a camera) e camera (un CameraComponent con viewport e viewfinder).

class SpaceGame extends FlameGame
    with HasCollisionDetection, HasKeyboardHandlerComponents {
  late final Player player;
  int score = 0;
  final _rng = Random();

  @override
  Future<void> onLoad() async {
    await images.loadAll(['player.png', 'enemy.png']);

    // Coordinate del mondo allineate ai pixel dello schermo,
    // origine in alto a sinistra.
    camera.viewfinder.anchor = Anchor.topLeft;

    player = Player()..position = Vector2(size.x / 2, size.y - 100);
    world.add(player);

    world.add(
      TimerComponent(period: 0.8, repeat: true, onTick: _spawnEnemy),
    );

    // L'HUD va nel viewport: non si muove con la camera.
    camera.viewport.add(ScoreText());
  }

  void _spawnEnemy() {
    final x = 24 + _rng.nextDouble() * (size.x - 48);
    world.add(Enemy()..position = Vector2(x, -40));
  }

  void gameOver() {
    pauseEngine();
    overlays.add('gameOver');
  }
}

onLoad() è asincrono ed è il posto giusto per caricare asset e costruire la scena. Gli altri hook del ciclo di vita sono onMount, onRemove e onGameResize.

Sprite, input e movimento

Un componente che disegna un'immagine è un SpriteComponent. Il mixin HasGameReference<T> dà accesso tipizzato all'istanza di gioco, KeyboardHandler riceve gli eventi da tastiera (utile su web e desktop), DragCallbacks quelli di trascinamento su mobile.

class Player extends SpriteComponent
    with HasGameReference<SpaceGame>, CollisionCallbacks, KeyboardHandler, DragCallbacks {
  Player() : super(size: Vector2.all(56), anchor: Anchor.center);

  static const double speed = 320;
  int _direction = 0;

  @override
  Future<void> onLoad() async {
    sprite = Sprite(game.images.fromCache('player.png'));
    add(RectangleHitbox(collisionType: CollisionType.active));
  }

  @override
  void update(double dt) {
    position.x += _direction * speed * dt;
    position.x = position.x.clamp(size.x / 2, game.size.x - size.x / 2);
  }

  @override
  bool onKeyEvent(KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    _direction = 0;
    if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) _direction -= 1;
    if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) _direction += 1;
    return false; // lascia propagare l'evento
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    position.x += event.localDelta.x;
  }
}

Per il tocco su un singolo componente esiste il mixin TapCallbacks con onTapDown/onTapUp: Flame calcola automaticamente quale componente è stato colpito in base a position, size e anchor.

Collisioni

Il sistema di collisioni si attiva con il mixin HasCollisionDetection sul gioco e con una hitbox su ogni componente coinvolto (RectangleHitbox, CircleHitbox, PolygonHitbox). I callback arrivano tramite CollisionCallbacks.

class Enemy extends SpriteComponent
    with HasGameReference<SpaceGame>, CollisionCallbacks {
  Enemy() : super(size: Vector2.all(48), anchor: Anchor.center);

  static const double speed = 140;

  @override
  Future<void> onLoad() async {
    sprite = Sprite(game.images.fromCache('enemy.png'));
    add(CircleHitbox());
  }

  @override
  void update(double dt) {
    position.y += speed * dt;
    if (position.y - size.y > game.size.y) {
      removeFromParent();
      game.score += 1;
    }
  }

  @override
  void onCollisionStart(Set<Vector2> points, PositionComponent other) {
    super.onCollisionStart(points, other);
    if (other is Player) {
      removeFromParent();
      game.gameOver();
    }
  }
}

Consigli pratici:

  • usa CollisionType.passive per gli oggetti che non devono essere testati fra loro (es. decine di nemici): dimezza i controlli;
  • rimuovi sempre dallo schermo i componenti usciti dai bordi con removeFromParent(), altrimenti l'albero cresce all'infinito;
  • attiva debugMode = true sul gioco per vedere hitbox e coordinate durante lo sviluppo.

Animazioni ed effetti

Per le animazioni a sprite sheet si usa SpriteAnimationComponent:

final explosion = await game.loadSpriteAnimation(
  'explosion.png',
  SpriteAnimationData.sequenced(
    amount: 8,
    stepTime: 0.05,
    textureSize: Vector2.all(64),
    loop: false,
  ),
);

game.world.add(
  SpriteAnimationComponent(
    animation: explosion,
    size: Vector2.all(64),
    anchor: Anchor.center,
    position: position.clone(),
    removeOnFinish: true,
  ),
);

Per movimenti, scalature e dissolvenze c'è il sistema di effects, analogo alle animazioni implicite di Flutter:

player.add(
  ScaleEffect.to(
    Vector2.all(1.2),
    EffectController(duration: 0.15, alternate: true, curve: Curves.easeOut),
  ),
);

enemy.add(
  MoveEffect.by(
    Vector2(0, 40),
    EffectController(duration: 0.4, infinite: true, alternate: true),
  ),
);

Gli effetti si rimuovono da soli al termine e si possono comporre in sequenza con SequenceEffect.

Camera, viewport e HUD

Il CameraComponent separa il mondo dalla vista. Per seguire il giocatore in un platform:

camera.follow(player);
camera.setBounds(Rectangle.fromLTWH(0, 0, levelWidth, levelHeight));

Per avere la stessa composizione su tutti i dispositivi conviene una risoluzione fissa, con Flame che aggiunge le bande o scala automaticamente:

camera = CameraComponent.withFixedResolution(
  world: world,
  width: 480,
  height: 960,
);

Tutto ciò che aggiungi a camera.viewport (punteggio, vite, pulsanti) resta ancorato allo schermo:

class ScoreText extends TextComponent with HasGameReference<SpaceGame> {
  ScoreText()
      : super(
          position: Vector2(16, 32),
          textRenderer: TextPaint(
            style: const TextStyle(fontSize: 24, color: Colors.white),
          ),
        );

  @override
  void update(double dt) => text = 'Punti: ${game.score}';
}

Integrare i widget Flutter con gli overlay

Menu, pause screen e dialog si scrivono meglio con i widget. Flame offre il sistema degli overlay: widget Flutter disegnati sopra il gioco e attivabili da codice.

GameWidget<SpaceGame>(
  game: SpaceGame(),
  loadingBuilder: (_) => const Center(child: CircularProgressIndicator()),
  overlayBuilderMap: {
    'gameOver': (context, game) => Center(
          child: Card(
            child: Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('Game over — ${game.score} punti'),
                  const SizedBox(height: 16),
                  FilledButton(
                    onPressed: () {
                      game.overlays.remove('gameOver');
                      game.resumeEngine();
                    },
                    child: const Text('Riprova'),
                  ),
                ],
              ),
            ),
          ),
        ),
  },
)

pauseEngine() e resumeEngine() fermano il game loop senza distruggere lo stato: perfetti per il menu di pausa o quando l'app va in background.

Audio

Con flame_audio (basato su audioplayers) il suono è immediato:

// precarica in onLoad per evitare latenza al primo colpo
await FlameAudio.audioCache.loadAll(['shoot.wav', 'explosion.wav']);

FlameAudio.play('shoot.wav', volume: 0.6);
FlameAudio.bgm.initialize();
FlameAudio.bgm.play('theme.mp3', volume: 0.3);

Ricorda di chiamare FlameAudio.bgm.stop() quando l'app passa in background per non consumare batteria.

Performance: le regole che contano davvero

  • Precarica gli asset in onLoad con images.loadAll: caricare un'immagine durante il gameplay provoca uno scatto.
  • Niente allocazioni nel loop: evita Vector2(...), Paint() o closure create a ogni update. Riusa istanze come campi del componente.
  • Object pooling per proiettili e particelle: invece di creare e distruggere centinaia di componenti, riattiva quelli già esistenti.
  • Usa priority per controllare l'ordine di disegno invece di riordinare manualmente la lista dei figli.
  • Disattiva debugMode nelle build di release e profila con DevTools come faresti con una normale app Flutter.
  • Su Android/iOS, Impeller riduce lo shader jank: verifica il frame rate in modalità profile, mai in debug.

L'ecosistema

Attorno al core esistono pacchetti ufficiali che coprono gli scenari più comuni:

  • flame_tiled: carica mappe create con l'editor Tiled;
  • flame_forge2d: fisica rigida basata su Box2D;
  • flame_audio: audio ed effetti sonori;
  • flame_bloc / flame_riverpod: ponte verso i tuoi state manager preferiti;
  • flame_test: utility come testWithGame per testare la logica di gioco senza UI.
testWithGame<SpaceGame>('il nemico esce dallo schermo e viene rimosso',
    SpaceGame.new, (game) async {
  final enemy = Enemy()..position = Vector2(100, 0);
  await game.world.add(enemy);
  await game.ready();

  game.update(10); // simula 10 secondi
  expect(enemy.isMounted, isFalse);
});

Conclusione

Flame non reinventa Flutter: lo estende con le astrazioni che servono per il tempo reale. Il modello mentale è familiare — un albero di componenti con un ciclo di vita — e la possibilità di mescolare widget e canvas rende semplice costruire menu, impostazioni e salvataggi con gli stessi strumenti che già usi. Parti da un prototipo piccolo (un componente, una collisione, un HUD), tieni la logica fuori dal render e usa sempre dt: il resto è iterazione e asset grafici.