Flutter is a UI library to build apps that run on any platform, but it can also build interactive games thanks to an open-source game engine built on top of Flutter called Flame. Flame takes care of things like collision detection or loading image sprites to bring game development to all the Flutter devs. We can take it a step further to introduce real-time communication features so that players can play against each other in real-time.
In this article, we will use Flutter, Flame, and Supabase's real-time features to build a real-time multiplayer shooting game. You can find the complete code of this tutorial here.
Overview of the final game
The game is a simple shooting game. Each player has a UFO, and you can move it by dragging your finger across the screen. The UFO will emit bullets automatically in three directions, and the objective of the game is to hit the opponents with bullets before your UFO gets destroyed by the opponent’s bullets. The position and the health points are synced using a low-latency web socket connection provided by Supabase.
Before entering the main game, there is a lobby where you can wait for other players to show up. Once another player shows up, you can hit start, which will kick off the game on both ends.
We will first build the Flutter widgets used to build the basic UI, then build the Flame game and finally handle the network connection to share the data between connected clients.
Build the App
Step 1. Create the Flutter App
We will start out by creating the Flutter app. Open your terminal and create a new app named with the following command.
_10flutter create flame_realtime_shooting
Open the created app with your favorite IDE and let’s get started with coding!
Step 2. Building the Flutter widgets
We will have a simple directory structure to build this app. Since we only have a few widgets, we will just add them inside main.dart
file.
_10├── lib/_10| ├── game/_10│ │ ├── game.dart_10│ │ ├── player.dart_10│ │ └── bullet.dart_10│ └── main.dart
Create the Main Game Page
We will create minimal Flutter widgets for this app as most of the game logic will be handled in the Flame Game classes later. Our game will have a single page with two dialogs before the game starts and after the game ends. The page will simply contain the GameWidget while displaying a nice background image. We will make it a StatefulWidget, because we will add methods to handle sending and receiving real-time data later on. Add the following to main.dart
file.
_81import 'package:flame/game.dart';_81import 'package:flame_realtime_shooting/game/game.dart';_81import 'package:flutter/material.dart';_81_81void main() {_81 runApp(const MyApp());_81}_81_81class MyApp extends StatelessWidget {_81 const MyApp({super.key});_81_81 @override_81 Widget build(BuildContext context) {_81 return const MaterialApp(_81 title: 'UFO Shooting Game',_81 debugShowCheckedModeBanner: false,_81 home: GamePage(),_81 );_81 }_81}_81_81class GamePage extends StatefulWidget {_81 const GamePage({Key? key}) : super(key: key);_81_81 @override_81 State<GamePage> createState() => _GamePageState();_81}_81_81class _GamePageState extends State<GamePage> {_81 late final MyGame _game;_81_81 @override_81 Widget build(BuildContext context) {_81 return Scaffold(_81 body: Stack(_81 fit: StackFit.expand,_81 children: [_81 Image.asset('assets/images/background.jpg', fit: BoxFit.cover),_81 GameWidget(game: _game),_81 ],_81 ),_81 );_81 }_81_81 @override_81 void initState() {_81 super.initState();_81 _initialize();_81 }_81_81 Future<void> _initialize() async {_81 _game = MyGame(_81 onGameStateUpdate: (position, health) async {_81 // TODO: handle gmae state update here_81 },_81 onGameOver: (playerWon) async {_81 // TODO: handle when the game is over here_81 },_81 );_81_81 // await for a frame so that the widget mounts_81 await Future.delayed(Duration.zero);_81_81 if (mounted) {_81 _openLobbyDialog();_81 }_81 }_81_81 void _openLobbyDialog() {_81 showDialog(_81 context: context,_81 barrierDismissible: false,_81 builder: (context) {_81 return _LobbyDialog(_81 onGameStarted: (gameId) async {_81 // handle game start here_81 },_81 );_81 });_81 }_81}
You will see some errors, because we are importing some files that we haven’t created yet, but don’t worry, because we will get to it soon.
Create the Lobby Dialog
Lobby dialog class on the surface is a simple Alert dialog, but will hold its own states like the list of players that are waiting at the lobby. We will also add some classes to handle the presence data on the lobby later on, but for now we will just have a simple AlertDialog. Add the following code at the end of main.dart file.
_45class _LobbyDialog extends StatefulWidget {_45 const _LobbyDialog({_45 required this.onGameStarted,_45 });_45_45 final void Function(String gameId) onGameStarted;_45_45 @override_45 State<_LobbyDialog> createState() => _LobbyDialogState();_45}_45_45class _LobbyDialogState extends State<_LobbyDialog> {_45 final List<String> _userids = [];_45 bool _loading = false;_45_45 /// TODO: assign unique identifier for the user_45 final myUserId = '';_45_45 @override_45 Widget build(BuildContext context) {_45 return AlertDialog(_45 title: const Text('Lobby'),_45 content: _loading_45 ? const SizedBox(_45 height: 100,_45 child: Center(child: CircularProgressIndicator()),_45 )_45 : Text('${_userids.length} users waiting'),_45 actions: [_45 TextButton(_45 onPressed: _userids.length < 2_45 ? null_45 : () async {_45 setState(() {_45 _loading = true;_45 });_45_45 // TODO: notify the other player the start of the game_45 },_45 child: const Text('start'),_45 ),_45 ],_45 );_45 }_45}
Step 3. Building the Flame components
Creating the FlameGame
Now the fun part starts! We will start out by creating our game class. We create a MyGame
class that extends the FlameGame class. FlameGame
takes care of collision detection and pan-detection, and it will also be the parent of all the components that we will add to the game. The game contains 2 components, Player
and Bullet
. MyGame
is a class that wraps around all components of the game and can control the child components.
Let’s add flame to our app by adding the following to our pubspec.yaml
file to install flame.
_10flame: ^1.6.0
We can then create MyGame
class. Add the following code in lib/game.dart
file.
_160import 'dart:async';_160_160import 'package:flame/game.dart';_160import 'package:flame/components.dart';_160import 'package:flame/events.dart';_160import 'package:flame/image_composition.dart' as flame_image;_160import 'package:flame_realtime_shooting/game/bullet.dart';_160import 'package:flame_realtime_shooting/game/player.dart';_160import 'package:flutter/material.dart';_160_160class MyGame extends FlameGame with PanDetector, HasCollisionDetection {_160 MyGame({_160 required this.onGameOver,_160 required this.onGameStateUpdate,_160 });_160_160 static const _initialHealthPoints = 100;_160_160 /// Callback to notify the parent when the game ends._160 final void Function(bool didWin) onGameOver;_160_160 /// Callback for when the game state updates._160 final void Function(_160 Vector2 position,_160 int health,_160 ) onGameStateUpdate;_160_160 /// `Player` instance of the player_160 late Player _player;_160_160 /// `Player` instance of the opponent_160 late Player _opponent;_160_160 bool isGameOver = true;_160_160 int _playerHealthPoint = _initialHealthPoints;_160_160 late final flame_image.Image _playerBulletImage;_160 late final flame_image.Image _opponentBulletImage;_160_160 @override_160 Color backgroundColor() {_160 return Colors.transparent;_160 }_160_160 @override_160 Future<void>? onLoad() async {_160 final playerImage = await images.load('player.png');_160 _player = Player(isMe: true);_160 final spriteSize = Vector2.all(Player.radius * 2);_160 _player.add(SpriteComponent(sprite: Sprite(playerImage), size: spriteSize));_160 add(_player);_160_160 final opponentImage = await images.load('opponent.png');_160 _opponent = Player(isMe: false);_160 _opponent.add(SpriteComponent.fromImage(opponentImage, size: spriteSize));_160 add(_opponent);_160_160 _playerBulletImage = await images.load('player-bullet.png');_160 _opponentBulletImage = await images.load('opponent-bullet.png');_160_160 await super.onLoad();_160 }_160_160 @override_160 void onPanUpdate(DragUpdateInfo info) {_160 _player.move(info.delta.global);_160 final mirroredPosition = _player.getMirroredPercentPosition();_160 onGameStateUpdate(mirroredPosition, _playerHealthPoint);_160 super.onPanUpdate(info);_160 }_160_160 @override_160 void update(double dt) {_160 super.update(dt);_160 if (isGameOver) {_160 return;_160 }_160 for (final child in children) {_160 if (child is Bullet && child.hasBeenHit && !child.isMine) {_160 _playerHealthPoint = _playerHealthPoint - child.damage;_160 final mirroredPosition = _player.getMirroredPercentPosition();_160 onGameStateUpdate(mirroredPosition, _playerHealthPoint);_160 _player.updateHealth(_playerHealthPoint / _initialHealthPoints);_160 }_160 }_160 if (_playerHealthPoint <= 0) {_160 endGame(false);_160 }_160 }_160_160 void startNewGame() {_160 isGameOver = false;_160 _playerHealthPoint = _initialHealthPoints;_160_160 for (final child in children) {_160 if (child is Player) {_160 child.position = child.initialPosition;_160 } else if (child is Bullet) {_160 child.removeFromParent();_160 }_160 }_160_160 _shootBullets();_160 }_160_160 /// shoots out bullets form both the player and the opponent._160 ///_160 /// Calls itself every 500 milliseconds_160 Future<void> _shootBullets() async {_160 await Future.delayed(const Duration(milliseconds: 500));_160_160 /// Player's bullet_160 final playerBulletInitialPosition = Vector2.copy(_player.position)_160 ..y -= Player.radius;_160 final playerBulletVelocities = [_160 Vector2(0, -100),_160 Vector2(60, -80),_160 Vector2(-60, -80),_160 ];_160 for (final bulletVelocity in playerBulletVelocities) {_160 add((Bullet(_160 isMine: true,_160 velocity: bulletVelocity,_160 image: _playerBulletImage,_160 initialPosition: playerBulletInitialPosition,_160 )));_160 }_160_160 /// Opponent's bullet_160 final opponentBulletInitialPosition = Vector2.copy(_opponent.position)_160 ..y += Player.radius;_160 final opponentBulletVelocities = [_160 Vector2(0, 100),_160 Vector2(60, 80),_160 Vector2(-60, 80),_160 ];_160 for (final bulletVelocity in opponentBulletVelocities) {_160 add((Bullet(_160 isMine: false,_160 velocity: bulletVelocity,_160 image: _opponentBulletImage,_160 initialPosition: opponentBulletInitialPosition,_160 )));_160 }_160_160 _shootBullets();_160 }_160_160 void updateOpponent({required Vector2 position, required int health}) {_160 _opponent.position = Vector2(size.x * position.x, size.y * position.y);_160 _opponent.updateHealth(health / _initialHealthPoints);_160 }_160_160 /// Called when either the player or the opponent has run out of health points_160 void endGame(bool playerWon) {_160 isGameOver = true;_160 onGameOver(playerWon);_160 }_160}
There is a lot going here, so let’s break it down. Within the onLoad
method, we are loading all of the sprites used throughout the game. Then we are adding the player and opponent component.
Within onPanUpdate
, we handle the user dragging on the screen. Note that we are calling the onGameStateUpdate
callback to pass the player’s position so that we can share it to the opponent’s client later when we handle network connections. On the other hand, we have the updateOpponent
method, which is used to update the opponent’s state with the information coming in from the network. We will also add logic to call it from the Flutter widgets later.
Upon starting the game, _shootBullets()
is called, which shoots out bullets both from the player and the opponent. _shootBullets()
is a recursive function that calls itself every 500 milliseconds. If the bullet hits the player, it is caught inside the udpate()
method, which is called on every frame. There we calculate the new player’s health points.
Creating the Player Component
Player
component has the UFO sprite and represents the player and the opponent. It extends the PositionComponent
from Flame. Add the following in lib/player.dart
_103import 'dart:async';_103_103import 'package:flame/collisions.dart';_103import 'package:flame/components.dart';_103import 'package:flame_realtime_shooting/game/bullet.dart';_103import 'package:flutter/material.dart';_103_103class Player extends PositionComponent with HasGameRef, CollisionCallbacks {_103 Vector2 velocity = Vector2.zero();_103_103 late final Vector2 initialPosition;_103_103 Player({required bool isMe}) : _isMyPlayer = isMe;_103_103 /// Whether it's me or the opponent_103 final bool _isMyPlayer;_103_103 static const radius = 30.0;_103_103 @override_103 Future<void>? onLoad() async {_103 anchor = Anchor.center;_103 width = radius * 2;_103 height = radius * 2;_103_103 final initialX = gameRef.size.x / 2;_103 initialPosition = _isMyPlayer_103 ? Vector2(initialX, gameRef.size.y * 0.8)_103 : Vector2(initialX, gameRef.size.y * 0.2);_103 position = initialPosition;_103_103 add(CircleHitbox());_103 add(_Gauge());_103 await super.onLoad();_103 }_103_103 void move(Vector2 delta) {_103 position += delta;_103 }_103_103 void updateHealth(double healthLeft) {_103 for (final child in children) {_103 if (child is _Gauge) {_103 child._healthLeft = healthLeft;_103 }_103 }_103 }_103_103 @override_103 void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {_103 super.onCollision(intersectionPoints, other);_103 if (other is Bullet && _isMyPlayer != other.isMine) {_103 other.hasBeenHit = true;_103 other.removeFromParent();_103 }_103 }_103_103 /// returns the mirrored percent position of the player_103 /// to be broadcasted to other clients_103 Vector2 getMirroredPercentPosition() {_103 final mirroredPosition = gameRef.size - position;_103 return Vector2(mirroredPosition.x / gameRef.size.x,_103 mirroredPosition.y / gameRef.size.y);_103 }_103}_103_103class _Gauge extends PositionComponent {_103 double _healthLeft = 1.0;_103_103 @override_103 FutureOr<void> onLoad() {_103 final playerParent = parent;_103 if (playerParent is Player) {_103 width = playerParent.width;_103 height = 10;_103 anchor = Anchor.centerLeft;_103 position = Vector2(0, 0);_103 }_103 return super.onLoad();_103 }_103_103 @override_103 void render(Canvas canvas) {_103 super.render(canvas);_103 canvas.drawRect(_103 Rect.fromPoints(_103 const Offset(0, 0),_103 Offset(width, height),_103 ),_103 Paint()..color = Colors.white);_103 canvas.drawRect(_103 Rect.fromPoints(_103 const Offset(0, 0),_103 Offset(width * _healthLeft, height),_103 ),_103 Paint()_103 ..color = _healthLeft > 0.5_103 ? Colors.green_103 : _healthLeft > 0.25_103 ? Colors.orange_103 : Colors.red);_103 }_103}
You can see that it has a _isMyPlayer
property, which is true for the player and false for the opponent. If we take a look at the onLoad
method, we can use this to position it either at the top if it’s the opponent, or at the bottom if it’s the player. We can also see that we are adding a CircleHitbox
, because we need to detect collisions between it and the bullets. Lastly, we are adding _Gauge
as its child, which is the health point gauge you see on top of each players. Within onCollision
callback, we are checking if the collided object is the opponent’s bullet, and if it is, we flag the bullet as hasBeenHit
and remove it from the game.
getMirroredPercentPosition
method is used when sharing the position with the opponent’s client. It calculates the mirrored position of the player. updateHealth
is called when the health changes and updates the bar length of the _Gauge
class.
Adding bullets
Lastly we will add the Bullet
class. It represents a single bullet coming out from the player and the opponent. Within onLoad
it adds the sprite to apply the nice image and CircleHitbox
so that it can collide with other objects. You can also see that it receives a velocity
in the constructor, the position is updated using the velocity and the elapsed time within the update
method. This is how you can have it move in a single direction at a constant speed.
_55import 'dart:async';_55_55import 'package:flame/collisions.dart';_55import 'package:flame/components.dart';_55import 'package:flame/image_composition.dart' as flame_image;_55_55class Bullet extends PositionComponent with CollisionCallbacks, HasGameRef {_55 final Vector2 velocity;_55_55 final flame_image.Image image;_55_55 static const radius = 5.0;_55_55 bool hasBeenHit = false;_55_55 final bool isMine;_55_55 /// Damage that it deals when it hits the player_55 final int damage = 5;_55_55 Bullet({_55 required this.isMine,_55 required this.velocity,_55 required this.image,_55 required Vector2 initialPosition,_55 }) : super(position: initialPosition);_55_55 @override_55 Future<void>? onLoad() async {_55 anchor = Anchor.center;_55_55 width = radius * 2;_55 height = radius * 2;_55_55 add(CircleHitbox()_55 ..collisionType = CollisionType.passive_55 ..anchor = Anchor.center);_55_55 final sprite =_55 SpriteComponent.fromImage(image, size: Vector2.all(radius * 2));_55_55 add(sprite);_55 await super.onLoad();_55 }_55_55 @override_55 void update(double dt) {_55 super.update(dt);_55 position += velocity * dt;_55_55 if (position.y < 0 || position.y > gameRef.size.y) {_55 removeFromParent();_55 }_55 }_55}
Step 4. Add real-time communications between players
At this point, we have a working shooting game except the opponent does not move, because we have not added any ways to communicate between clients. We will use Supabase’s realtime features for this, because it gives us an out of the box solution to handle low-latency real-time communication between players. If you do not have a Supabase project created yet, head over to database.new to create one.
Before we get into any coding, let’s install the Supabase SDK into our app. We will also use the uuid package to generate random unique ids for the users. Add the following to pubspec.yaml
file.
_10supabase_flutter: ^1.4.0_10uuid: ^3.0.7
Once pub get
is complete, let’s initialize Supabase. We will override the main
function to initialize Supabase. You can get your Supabase URL and Anon Key at Project Setting
> API
. Copy and paste them into the Supabase.initialize
call.
_11void main() async {_11await Supabase.initialize(_11url: 'YOUR_SUPABASE_URL',_11anonKey: 'YOUR_ANON_KEY',_11realtimeClientOptions: const RealtimeClientOptions(eventsPerSecond: 40),_11);_11runApp(const MyApp());_11}_11_11// Extract Supabase client for easy access to Supabase_11final supabase = Supabase.instance.client;
RealtimeClientOptions
here is a parameter to override how many events per second each client will send to Supabase. The default is 10, but we want to override to 40 to provide a more in-synced experience.
With this, we are ready to get into adding the real-time features now.
Handle the Lobby to wait for Other Players to show up
We will start by rewriting the _Lobby
class the first thing we have to do in the lobby is to wait and detect other online users also at the lobby. We can implement this using the presence feature in Supabase.
Add initState
and inside it initialize a RealtimeChannel
instance. We can call it _lobbyChannel
. If we take a look at the subscribe()
method, we can see that upon successful subscription to the lobby channel, we are tracking our the unique user ID that we create uplon initialization. We are listening to the sync
event to get notified whenever anyone is “present”. Within the callback, we are extracting the userIds of all the users in the lobby and set it as the state.A game starts when someone taps on the Start
button. If we take a look at the onPressed
callback, we see that we are sending a broadcast event to the lobby channel with two participant ids and a randomly generated game ID. Broadcast is a feature of Supabase to send and receive lightweight low-latency data between clients, and when the two participants, one of them being the person tapping on the start
button, is received on both ends, a game starts. We can observe within initState
inside the callback for game_start
event that upon receiving a broadcast event, it checks if the player is one of the participants, and if it is, it will call the onGameStarted
call back and pop the navigator removing the dialog. The game has begun!
_92class _LobbyDialogState extends State<_LobbyDialog> {_92 List<String> _userids = [];_92 bool _loading = false;_92_92 /// Unique identifier for each players to identify eachother in lobby_92 final myUserId = const Uuid().v4();_92_92 late final RealtimeChannel _lobbyChannel;_92_92 @override_92 void initState() {_92 super.initState();_92_92 _lobbyChannel = supabase.channel(_92 'lobby',_92 opts: const RealtimeChannelConfig(self: true),_92 );_92 _lobbyChannel.on(RealtimeListenTypes.presence, ChannelFilter(event: 'sync'),_92 (payload, [ref]) {_92 // Update the lobby count_92 final presenceState = _lobbyChannel.presenceState();_92_92 setState(() {_92 _userids = presenceState.values_92 .map((presences) =>_92 (presences.first as Presence).payload['user_id'] as String)_92 .toList();_92 });_92 }).on(RealtimeListenTypes.broadcast, ChannelFilter(event: 'game_start'),_92 (payload, [_]) {_92 // Start the game if someone has started a game with you_92 final participantIds = List<String>.from(payload['participants']);_92 if (participantIds.contains(myUserId)) {_92 final gameId = payload['game_id'] as String;_92 widget.onGameStarted(gameId);_92 Navigator.of(context).pop();_92 }_92 }).subscribe(_92 (status, [ref]) async {_92 if (status == 'SUBSCRIBED') {_92 await _lobbyChannel.track({'user_id': myUserId});_92 }_92 },_92 );_92 }_92_92 @override_92 void dispose() {_92 supabase.removeChannel(_lobbyChannel);_92 super.dispose();_92 }_92_92 @override_92 Widget build(BuildContext context) {_92 return AlertDialog(_92 title: const Text('Lobby'),_92 content: _loading_92 ? const SizedBox(_92 height: 100,_92 child: Center(child: CircularProgressIndicator()),_92 )_92 : Text('${_userids.length} users waiting'),_92 actions: [_92 TextButton(_92 onPressed: _userids.length < 2_92 ? null_92 : () async {_92 setState(() {_92 _loading = true;_92 });_92_92 final opponentId =_92 _userids.firstWhere((userId) => userId != myUserId);_92 final gameId = const Uuid().v4();_92 await _lobbyChannel.send(_92 type: RealtimeListenTypes.broadcast,_92 event: 'game_start',_92 payload: {_92 'participants': [_92 opponentId,_92 myUserId,_92 ],_92 'game_id': gameId,_92 },_92 );_92 },_92 child: const Text('start'),_92 ),_92 ],_92 );_92 }_92}
Sharing Game States with the Opposing Player
Once a game begins, we need to synchronize the game states between the two clients. In our case, we will sync only the player’s position and health points. Whenever a player moves, or the player’s health points change, the onGameStateUpdate
callback on our MyGame
instance that will fire notifying the update along with its position and health point. We will broadcast those information to the opponent’s client via Supabase broadcast feature.
Fill in the _initialize
method like the following to initialize the game.
_119class GamePage extends StatefulWidget {_119 const GamePage({Key? key}) : super(key: key);_119_119 @override_119 State<GamePage> createState() => _GamePageState();_119}_119_119class _GamePageState extends State<GamePage> {_119 late final MyGame _game;_119_119 /// Holds the RealtimeChannel to sync game states_119 RealtimeChannel? _gameChannel;_119_119 @override_119 Widget build(BuildContext context) {_119 return Scaffold(_119 body: Stack(_119 fit: StackFit.expand,_119 children: [_119 Image.asset('assets/images/background.jpg', fit: BoxFit.cover),_119 GameWidget(game: _game),_119 ],_119 ),_119 );_119 }_119_119 @override_119 void initState() {_119 super.initState();_119 _initialize();_119 }_119_119 Future<void> _initialize() async {_119 _game = MyGame(_119 onGameStateUpdate: (position, health) async {_119 ChannelResponse response;_119 // Loop until the send succeeds if the payload is to notify defeat._119 do {_119 response = await _gameChannel!.send(_119 type: RealtimeListenTypes.broadcast,_119 event: 'game_state',_119 payload: {'x': position.x, 'y': position.y, 'health': health},_119 );_119_119 // wait for a frame to avoid infinite rate limiting loops_119 await Future.delayed(Duration.zero);_119 setState(() {});_119 } while (response == ChannelResponse.rateLimited && health <= 0);_119 },_119 onGameOver: (playerWon) async {_119 await showDialog(_119 barrierDismissible: false,_119 context: context,_119 builder: ((context) {_119 return AlertDialog(_119 title: Text(playerWon ? 'You Won!' : 'You lost...'),_119 actions: [_119 TextButton(_119 onPressed: () async {_119 Navigator.of(context).pop();_119 await supabase.removeChannel(_gameChannel!);_119 _openLobbyDialog();_119 },_119 child: const Text('Back to Lobby'),_119 ),_119 ],_119 );_119 }),_119 );_119 },_119 );_119_119 // await for a frame so that the widget mounts_119 await Future.delayed(Duration.zero);_119_119 if (mounted) {_119 _openLobbyDialog();_119 }_119 }_119_119 void _openLobbyDialog() {_119 showDialog(_119 context: context,_119 barrierDismissible: false,_119 builder: (context) {_119 return _LobbyDialog(_119 onGameStarted: (gameId) async {_119 // await a frame to allow subscribing to a new channel in a realtime callback_119 await Future.delayed(Duration.zero);_119_119 setState(() {});_119_119 _game.startNewGame();_119_119 _gameChannel = supabase.channel(gameId,_119 opts: const RealtimeChannelConfig(ack: true));_119_119 _gameChannel!.on(RealtimeListenTypes.broadcast,_119 ChannelFilter(event: 'game_state'), (payload, [_]) {_119 final position =_119 Vector2(payload['x'] as double, payload['y'] as double);_119 final opponentHealth = payload['health'] as int;_119 _game.updateOpponent(_119 position: position,_119 health: opponentHealth,_119 );_119_119 if (opponentHealth <= 0) {_119 if (!_game.isGameOver) {_119 _game.isGameOver = true;_119 _game.onGameOver(true);_119 }_119 }_119 }).subscribe();_119 },_119 );_119 });_119 }_119}
You can see that within _openLobbyDialog
, there is an onGameStarted
callback for when the game has started. Once a game has been started, it creates a new channel using the game ID as the channel name and starts listening to game state updates from the opponent.You can see that within the onGameOver
callback, we are showing a simple dialog. Upon tapping Back to Lobby
, the user will be taken back to the lobby dialog, where they can start another game if they want to.
With all of that put together, we have a functioning real-time multiplayer shooting game. Grab a friend, run the app with flutter run
, and have fun with it!
Conclusions
We learned how to create an interactive shooting game. We took advantage of Flutter’s dialogs to create a quick and easy lobby and post-game UI. Then we created the game using Flame. We learned how to detect and handle collisions and experienced how easy creating a sophisticated game was using Flame. Finally, we added capabilities to share game states with other clients to complete a real-time multiplayer experience without managing our own infrastructure using Supabase.