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, dovedtè 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.passiveper 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 = truesul 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
onLoadconimages.loadAll: caricare un'immagine durante il gameplay provoca uno scatto. - Niente allocazioni nel loop: evita
Vector2(...),Paint()o closure create a ogniupdate. 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
priorityper controllare l'ordine di disegno invece di riordinare manualmente la lista dei figli. - Disattiva
debugModenelle 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 cometestWithGameper 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.