Real-time Multiplayer Game with Flutter
Build a real-time synchronicity game with Supabase Realtime and Edge Functions.
Introduction
30 Second Summary
Think about those moments when you and a friend say the exact same thing at the exact same time. What if you could turn that into a competitive game?
In this project, you will build Mirror Match, a real-time multiplayer game where two players make simultaneous blind choices and discover their synchronicity score. You'll use Flutter, Supabase Realtime, and Edge Functions to create a production-grade multiplayer experience.
What You'll Build
A real-time synchronicity game built with Flutter and Supabase that pairs players together, synchronizes their gameplay through Realtime Broadcast, and validates choices server-side with a TypeScript Edge Function.
By the end of this project, you'll have:
- A Flutter app with clean architecture and BLoC managing a complex real-time game state machine.
- A deployed Supabase Edge Function that validates player choices server-side and prevents cheating through timestamp verification.
- Real-time matchmaking using Supabase Presence and game synchronization with Realtime Broadcast.
- Row Level Security policies that enforce game privacy at the database level.
- Secret Mission: Add a rematch feature with a persistent rivalry tracker showing sync history between recurring opponents.
Are there any prerequisites?
This project assumes intermediate Flutter development experience, familiarity with the BLoC pattern and clean architecture, and basic SQL knowledge. You'll also need a free Supabase account, the Flutter SDK, and Android Studio already installed.
You do not need prior TypeScript experience. The Edge Function code is short and fully guided.
Set Up Your Project and Supabase
Mirror Match needs three foundational pieces before you write any game logic: a Supabase cloud project to power real-time multiplayer, Docker to test Edge Functions locally, and a Flutter project structured with clean architecture.
In this step, you will install the tools, create a Supabase project, and scaffold your Flutter app with separated data, domain, and presentation layers.
In this step, get ready to:
- Install Supabase CLI and Docker Desktop.
- Create a new Supabase project and note your credentials.
- Scaffold a Flutter project with clean architecture folders and dependencies.
Install the Supabase CLI and Docker Desktop
The Supabase CLI lets you manage your database, deploy Edge Functions, and test them locally. It installs through Scoop, a command-line package manager for Windows.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Check if Scoop is already installed by running:
scoop --version
✔️ I see a version number
Scoop is ready. Skip ahead to the Supabase CLI install command below.
ⓧ Command not found
You need to install Scoop first. This takes two commands.
- Allow PowerShell to run remote scripts by running:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
- When prompted, type A and press Enter to accept.
- Install Scoop by running:
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
- Close and reopen PowerShell after installation completes.
- Verify Scoop works by running:
scoop --version
You should see a version number printed in the terminal.
Scoop is not recognized?
This usually means PowerShell has not refreshed your PATH. Close PowerShell completely and reopen it. If it still fails, confirm Scoop installed to your user scoop folder at C:\Users\YourName\scoop.
Help me troubleshoot my Scoop installation on Windows.
- Install the Supabase CLI by running these two commands:
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabase
- Verify the Supabase CLI is installed by running:
supabase --version
You should see a version number printed in the output.
Supabase command not found?
Make sure the scoop bucket add command completed without errors before running scoop install supabase. Close and reopen PowerShell, then try again.
Help me fix my Supabase CLI installation.
Next, install Docker Desktop. You need it later to test Edge Functions locally on your machine.
- Open your browser and go to https://docs.docker.com/desktop/install/windows-install.
- Click Docker Desktop for Windows - x86_64 to download the installer.
- Double-click Docker Desktop Installer.exe in your Downloads folder.
- When the installer asks for the backend, ensure the Use WSL 2 instead of Hyper-V checkbox is selected on the Configuration page.
- Follow the wizard prompts and click Close when installation finishes.
- Docker Desktop should launch automatically. If not, press the Windows key, type Docker Desktop, and press Enter.
- Accept the Docker service agreement when prompted.
Why Docker Desktop?
Docker provides the runtime that Supabase needs to serve Edge Functions locally for testing. You will not need to write Dockerfiles or manage containers yourself.
If your machine is low on RAM, you can skip Docker for now and use the Supabase Dashboard to edit and deploy Edge Functions directly in Step 5.
- Verify Docker is running by going back to your PowerShell terminal and running:
docker --version
You should see output like Docker version 27.x.x confirming Docker is available.
Docker command not recognized?
Docker Desktop takes a minute or two to fully start after installation. Wait for the whale icon in your system tray to stop animating, then close and reopen PowerShell.
Help me get Docker Desktop working on Windows.
Create your Supabase project
Your Supabase project is the backend for Mirror Match. It provides the database, authentication, real-time messaging, and Edge Function hosting.
- Open your browser and go to https://supabase.com/dashboard.
- Sign in to your Supabase account.
- Click New Project.
- Enter mirror-match as the project name.
- Enter a strong database password and save it somewhere safe. You will need it later.
- Select a region close to you.
- Click Create new project.
Supabase takes about 2 minutes to provision your project. Wait for the dashboard to finish loading.
- Once the project is ready, click Settings in the left sidebar.
- Click API under the Configuration section.
- Copy your Project URL (looks like https://abc123.supabase.co) and paste it somewhere you can find it later.
- Copy the anon public key (a long string starting with eyJ) and save it alongside the URL.
What are these credentials?
The Project URL is your Supabase backend address. The anon key is a public key that identifies your app to Supabase.
It is safe to embed in client-side code because Row Level Security policies protect your data at the database level.
Free tier project limits
The Supabase free tier allows 2 active projects. Projects pause after 1 week of inactivity. If your project pauses while you are working through this guide over multiple sessions, click Restore in the dashboard to unpause it.
Scaffold the Flutter project with clean architecture
Clean architecture separates your code into layers: data (API calls, models), domain (business logic, entities), and presentation (UI, BLoC state management).
This structure keeps the codebase maintainable as the game grows. You can swap out Supabase for a different backend without touching your game logic or UI.
- In your PowerShell terminal, move to your Desktop by running this command:
cd ~/Desktop
- Create the Flutter project by running:
flutter create mirror_match
What does flutter create do?
This command scaffolds a new Flutter project with a default app structure, Gradle build files for Android, and a lib/main.dart entry point. You will replace the default code shortly.
- Move into the project folder by running:
cd mirror_match
- Create the clean architecture folder structure by running:
mkdir lib/core, lib/data/datasources, lib/data/models, lib/data/repositories, lib/domain/entities, lib/domain/repositories, lib/domain/usecases, lib/presentation/blocs, lib/presentation/screens, lib/presentation/widgets
- Confirm the folders were created by running:
ls lib
You should see folders named core, data, domain, and presentation listed in the output alongside the existing main.dart file.
What is clean architecture?
Each layer has a single responsibility. The data layer talks to Supabase. The domain layer defines game rules and entities. The presentation layer handles UI and user interactions.
- Open your code editor. Press the Windows key to open your search bar.
- Type Android Studio and press Enter to open it.
- In Android Studio, click File in the top menu bar.
- Click Open.
- Navigate to your Desktop and select the mirror_match folder, then click OK.
- Open the file pubspec.yaml in the project root from the left file tree.
- Replace the entire contents of pubspec.yaml with the following:
name: mirror_match
description: Real-time synchronicity game
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
supabase_flutter: ^2.0.0
flutter_bloc: ^8.1.3
equatable: ^2.0.5
get_it: ^7.6.4
dartz: ^0.10.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
What do these dependencies do?
- supabase_flutter connects your app to Supabase for auth, database, and real-time features.
- flutter_bloc provides the BLoC pattern for managing complex game state machines.
- equatable makes BLoC state comparisons cleaner by generating equality checks automatically.
- get_it is a service locator for dependency injection across layers.
- dartz provides functional programming types like Either for clean error handling.
- Save pubspec.yaml.
- Open lib/main.dart from the left file tree.
- Replace the entire contents of main.dart with the following:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async {
// Ensure Flutter bindings are ready before async calls
WidgetsFlutterBinding.ensureInitialized();
// Initialize Supabase with your project credentials
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
);
runApp(const MirrorMatchApp());
}
// Root widget for the Mirror Match app
class MirrorMatchApp extends StatelessWidget {
const MirrorMatchApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mirror Match',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
// Placeholder home screen, replaced with game screens later
home: const Scaffold(
body: Center(child: Text('Mirror Match')),
),
);
}
}
What does this code do?
- WidgetsFlutterBinding.ensureInitialized() prepares Flutter for async operations before the app starts.
- Supabase.initialize() connects your app to your Supabase project using the URL and anon key you saved earlier.
- MirrorMatchApp renders a placeholder screen. You will replace this with game screens in later steps.
- Replace 'YOUR_SUPABASE_URL' with your actual Supabase Project URL that you saved earlier.
- Replace 'YOUR_SUPABASE_ANON_KEY' with your actual anon public key.
- Save main.dart.
- Install the project dependencies by running this command in your terminal (make sure you are in the mirror_match folder):
flutter pub get
You should see output ending with a message confirming all packages were resolved successfully.
Seeing dependency resolution errors?
Make sure your pubspec.yaml does not have any extra spaces or indentation issues. YAML is sensitive to whitespace.
Double-check that your sdk constraint matches your installed Flutter SDK version.
Help me fix my pubspec.yaml dependency errors.
- Connect your physical Android device to your computer via USB.
- Make sure USB debugging is enabled on your device (Settings > Developer Options > USB Debugging).
- Your device will show a prompt asking to allow USB debugging from this computer. Tap Allow.
- Run the app on your device by running:
flutter run
Your device should display a screen with the text "Mirror Match" centered on a white background.
App not launching on your device?
Run flutter devices to confirm your phone appears in the list. If it does not, check that USB debugging is enabled and that you accepted the "Allow USB debugging" prompt on your phone.
Help me connect my Android device for Flutter development.
✔️ Awesome, I've got everything!
Great. Double-check you have saved both pubspec.yaml and lib/main.dart.
ⓧ I'd like to double check the full code
Here is the complete pubspec.yaml:
name: mirror_match
description: Real-time synchronicity game
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
supabase_flutter: ^2.0.0
flutter_bloc: ^8.1.3
equatable: ^2.0.5
get_it: ^7.6.4
dartz: ^0.10.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true
Here is the complete lib/main.dart:
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
);
runApp(const MirrorMatchApp());
}
class MirrorMatchApp extends StatelessWidget {
const MirrorMatchApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mirror Match',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const Scaffold(
body: Center(child: Text('Mirror Match')),
),
);
}
}
Your development environment is fully set up. Next, you will create the database tables and security policies that power the game's multiplayer features.
Build the Database Schema and Security
Your Flutter app is running, but it has nowhere to store game data yet. A multiplayer game needs structured tables to track who is playing, which session they belong to, and what choices they make each round.
In this step, you'll create the database schema in Supabase and lock it down with Row Level Security. RLS ensures players can only see their own game data. No peeking at your opponent's choices.
In this step, get ready to:
- Create three database tables in the Supabase SQL Editor.
- Enable Row Level Security with policies that restrict game data to participants.
- Enable Realtime publication for live game synchronization.
Create the database tables in the SQL Editor
Mirror Match needs three tables: one for player profiles, one for game sessions, and one for individual rounds within a session. Each table references the one above it through foreign keys, creating a clear hierarchy.
- Open your Supabase project dashboard in your browser.
- Click SQL Editor in the left sidebar.
- Click New query to open a blank editor.
- Create the profiles table by pasting this SQL into the editor and clicking Run:
-- Profiles table
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
username text,
created_at timestamptz default now()
);
What does this table do?
- id links directly to the built-in auth.users table that Supabase Auth manages for you.
- on delete cascade means if a user account is deleted, their profile disappears too.
- username is nullable because players sign in anonymously first and pick a name later.
- You should see Success. No rows returned at the bottom of the editor.
Seeing a permission denied error?
Make sure you are using the SQL Editor inside your project dashboard (not a local Supabase instance). The SQL Editor runs with full database owner privileges by default.
If you see relation "auth.users" does not exist, confirm you selected the correct project from your dashboard home page.
help me fix my SQL Editor permissions error
- Click New query again to open a fresh editor tab.
- Create the game_sessions table by pasting this SQL and clicking Run:
-- Game sessions table
create table public.game_sessions (
id uuid primary key default gen_random_uuid(),
player1_id uuid references public.profiles(id),
player2_id uuid references public.profiles(id),
status text default 'waiting',
sync_score int default 0,
total_rounds int default 20,
current_round int default 0,
created_at timestamptz default now(),
completed_at timestamptz
);
What does this table do?
Each row represents one game between two players. The status field tracks where the game is in its lifecycle: waiting, playing, or completed.
gen_random_uuid() auto-generates a unique ID for every new session. The sync_score increments each time both players pick the same choice in a round.
- Confirm you see Success. No rows returned again.
- Open one more new query tab.
- Create the rounds table by pasting this SQL and clicking Run:
-- Rounds table
create table public.rounds (
id uuid primary key default gen_random_uuid(),
game_session_id uuid references public.game_sessions(id) on delete cascade,
round_number int not null,
player1_choice text,
player2_choice text,
is_sync bool,
revealed_at timestamptz,
created_at timestamptz default now()
);
What does this table do?
Each row is one round of a game. The player1_choice and player2_choice fields start as null and get filled in by the Edge Function as players submit.
is_sync is true when both players chose the same thing. The on delete cascade ensures all rounds are cleaned up if a game session is deleted.
- Confirm you see Success. No rows returned.
- Click Table Editor in the left sidebar to verify all three tables appear in the list: profiles, game_sessions, and rounds.
Tables not showing in Table Editor?
Make sure you are viewing the public schema in the Table Editor dropdown (top left of the table list). The tables were created in the public schema by default.
If a table is missing, go back to the SQL Editor and re-run the query for that table. Check the error output at the bottom for details.
help me find my tables in the Supabase Table Editor
Lock down tables with Row Level Security
Without RLS, any authenticated user could read every game session in your database. RLS policies act as row-level filters that automatically apply to every query.
- Go back to the SQL Editor and open a new query.
- Enable RLS on all three tables and add policies for the profiles table by pasting this SQL and clicking Run:
-- Enable RLS
alter table public.profiles enable row level security;
alter table public.game_sessions enable row level security;
alter table public.rounds enable row level security;
-- Profiles policies
create policy "Users can view own profile" on public.profiles
for select using (auth.uid() = id);
create policy "Users can update own profile" on public.profiles
for update using (auth.uid() = id);
create policy "Users can insert own profile" on public.profiles
for insert with check (auth.uid() = id);
What do these policies do?
The three ALTER TABLE statements flip RLS on. Once enabled, all queries through the API are blocked unless a policy explicitly allows them.
The profiles policies use auth.uid() = id to ensure a player can only read, update, or insert their own profile row. Nobody else can see your username or modify your data.
- Confirm you see Success. No rows returned.
- Open a new query tab.
- Add policies for the game_sessions table by pasting this SQL and clicking Run:
-- Game sessions policies
create policy "Participants can view their sessions" on public.game_sessions
for select using (
auth.uid() = player1_id or auth.uid() = player2_id
);
create policy "Authenticated users can create sessions" on public.game_sessions
for insert with check (auth.uid() = player1_id);
create policy "Participants can update their sessions" on public.game_sessions
for update using (
auth.uid() = player1_id or auth.uid() = player2_id
);
Why check both player IDs?
The select and update policies check auth.uid() = player1_id or auth.uid() = player2_id. This means only the two people in a game can view or modify that session.
The insert policy only checks player1_id because the person creating the session is always player 1. Player 2 gets added later during matchmaking.
- Confirm you see Success. No rows returned.
- Open a new query tab.
- Add policies for the rounds table by pasting this SQL and clicking Run:
-- Rounds policies
create policy "Participants can view rounds" on public.rounds
for select using (
exists (
select 1 from public.game_sessions gs
where gs.id = game_session_id
and (gs.player1_id = auth.uid() or gs.player2_id = auth.uid())
)
);
create policy "Participants can insert rounds" on public.rounds
for insert with check (
exists (
select 1 from public.game_sessions gs
where gs.id = game_session_id
and (gs.player1_id = auth.uid() or gs.player2_id = auth.uid())
)
);
Why use a subquery here?
The rounds table does not directly store player IDs. It only has a game_session_id foreign key.
The exists subquery looks up the parent game session and checks whether the current user is one of its two players. This guarantees you can only see rounds from games you are participating in.
- Confirm you see Success. No rows returned.
- Click Authentication in the left sidebar, then click Policies to see all your RLS policies listed for each table.
Seeing a duplicate policy error?
If you ran a query more than once, the policy already exists. You can safely ignore this error or drop the existing policy first with:
drop policy "Policy Name Here" on public.table_name;
Replace the policy name and table name with the ones from the error message, then re-run the create statement.
help me fix duplicate RLS policy errors
Enable Realtime for live game updates
Supabase Realtime can push database changes to connected clients instantly. You need to explicitly opt tables into this system.
- Open a new query in the SQL Editor.
- Enable Realtime publication for the game tables by pasting this SQL and clicking Run:
-- Enable Realtime
alter publication supabase_realtime add table public.game_sessions;
alter publication supabase_realtime add table public.rounds;
Why only these two tables?
The profiles table rarely changes after creation, so it does not need Realtime. The game_sessions and rounds tables change constantly during gameplay.
Publishing a table to supabase_realtime means any Postgres change on that table can be streamed to subscribed clients. You will use this for game state updates and stats in later steps.
- Confirm you see Success. No rows returned.
- Click Database in the left sidebar, then click Publications.
- You should see supabase_realtime listed with game_sessions and rounds as published tables.
Publication not showing the tables?
If the publication page does not list your tables, try refreshing the browser page. The dashboard occasionally caches the previous state.
If you see relation does not exist when running the ALTER PUBLICATION query, make sure you created the tables in a previous query first. The tables must exist before they can be published.
help me enable Realtime publication for my Supabase tables
✔️ Awesome, I've got everything!
Great. Make sure you ran all the queries and can see your three tables in the Table Editor and your policies under Authentication > Policies.
ⓧ I'd like to double check the full SQL
Here is the complete SQL for everything you ran in this step. You can paste this into a single new query and run it to recreate everything from scratch (you would need to drop the existing tables first).
-- Profiles table
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
username text,
created_at timestamptz default now()
);
-- Game sessions table
create table public.game_sessions (
id uuid primary key default gen_random_uuid(),
player1_id uuid references public.profiles(id),
player2_id uuid references public.profiles(id),
status text default 'waiting',
sync_score int default 0,
total_rounds int default 20,
current_round int default 0,
created_at timestamptz default now(),
completed_at timestamptz
);
-- Rounds table
create table public.rounds (
id uuid primary key default gen_random_uuid(),
game_session_id uuid references public.game_sessions(id) on delete cascade,
round_number int not null,
player1_choice text,
player2_choice text,
is_sync bool,
revealed_at timestamptz,
created_at timestamptz default now()
);
-- Enable RLS
alter table public.profiles enable row level security;
alter table public.game_sessions enable row level security;
alter table public.rounds enable row level security;
-- Profiles policies
create policy "Users can view own profile" on public.profiles
for select using (auth.uid() = id);
create policy "Users can update own profile" on public.profiles
for update using (auth.uid() = id);
create policy "Users can insert own profile" on public.profiles
for insert with check (auth.uid() = id);
-- Game sessions policies
create policy "Participants can view their sessions" on public.game_sessions
for select using (
auth.uid() = player1_id or auth.uid() = player2_id
);
create policy "Authenticated users can create sessions" on public.game_sessions
for insert with check (auth.uid() = player1_id);
create policy "Participants can update their sessions" on public.game_sessions
for update using (
auth.uid() = player1_id or auth.uid() = player2_id
);
-- Rounds policies
create policy "Participants can view rounds" on public.rounds
for select using (
exists (
select 1 from public.game_sessions gs
where gs.id = game_session_id
and (gs.player1_id = auth.uid() or gs.player2_id = auth.uid())
)
);
create policy "Participants can insert rounds" on public.rounds
for insert with check (
exists (
select 1 from public.game_sessions gs
where gs.id = game_session_id
and (gs.player1_id = auth.uid() or gs.player2_id = auth.uid())
)
);
-- Enable Realtime
alter publication supabase_realtime add table public.game_sessions;
alter publication supabase_realtime add table public.rounds;
Your database is structured and secured. Next up, you'll wire up anonymous authentication so players can start a game instantly without creating an account.
Implement Authentication and Profiles
Your database is secured and ready. Now you need players to actually identify themselves so the game knows who is making choices and who to pair together.
For a game app, you want zero friction. Players should be able to tap and play immediately without creating an account. Supabase supports anonymous sign-in, which gives each device a unique user ID without requiring email or password. You will implement this, then layer a username on top so players can recognize each other.
In this step, get ready to:
- Implement anonymous auth sign-in with automatic profile creation.
- Create the AuthBloc state machine that manages authentication flow.
- Build a username entry screen and wire up app navigation.
Create the auth data source
In clean architecture, the data layer handles all communication with external services. You will create a data source class that wraps Supabase Auth calls and profile table operations.
Before writing any code, you need to enable anonymous sign-ins in your Supabase project. This feature is disabled by default.
- Open your Supabase project dashboard in the browser.
- In the left sidebar, click Authentication.
- Click Providers in the submenu.
- Scroll down to find Anonymous Sign-In and toggle it on.
- Click Save.
Why anonymous auth?
Anonymous sign-in creates a real user in Supabase Auth without requiring credentials. The player gets a unique ID immediately, which means your RLS policies work from the first tap.
You can always upgrade anonymous users to email-based accounts later. For a game, instant play is more important than collecting emails upfront.
- In your Flutter project, create the file lib/data/datasources/auth_remote_data_source.dart and add the following code:
import 'package:supabase_flutter/supabase_flutter.dart';
// Abstract class defines the contract for auth operations
abstract class AuthRemoteDataSource {
Future<void> signInAnonymously();
Future<void> updateUsername(String userId, String username);
Future<String?> getUsername(String userId);
}
// Concrete implementation using Supabase
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final SupabaseClient client;
AuthRemoteDataSourceImpl(this.client);
@override
Future<void> signInAnonymously() async {
// Sign in and auto-create a profile row
await client.auth.signInAnonymously();
final userId = client.auth.currentUser!.id;
await client.from('profiles').upsert({'id': userId});
}
@override
Future<void> updateUsername(String userId, String username) async {
await client.from('profiles').update({'username': username}).eq('id', userId);
}
@override
Future<String?> getUsername(String userId) async {
final data = await client.from('profiles').select('username').eq('id', userId).single();
return data['username'] as String?;
}
}
What does this code do?
- The abstract class AuthRemoteDataSource defines the interface. This lets you swap implementations for testing.
- signInAnonymously() calls Supabase Auth and then uses upsert to create a profiles row. Upsert means "insert or do nothing if it exists" so it is safe to call multiple times.
- getUsername() queries the profiles table to check if the player has set a name yet.
- Save auth_remote_data_source.dart.
Build the AuthBloc state machine
The BLoC pattern separates business logic from UI. Your AuthBloc will receive events (app started, username submitted) and emit states that the UI reacts to.
You will create three files: events, states, and the bloc itself. They use Dart's part directive to stay in one compilation unit while keeping each file focused.
- Create the file lib/presentation/blocs/auth/auth_event.dart and add the following code:
part of 'auth_bloc.dart';
// Events that can be dispatched to the AuthBloc
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
// Dispatched when the app first launches
class AppStarted extends AuthEvent {}
// Dispatched when the player submits a username
class UpdateUsername extends AuthEvent {
final String username;
UpdateUsername(this.username);
@override
List<Object?> get props => [username];
}
What does this code do?
- AppStarted fires once when the app launches. The bloc will use it to check if a user session already exists.
- UpdateUsername carries the username string to save. Equatable ensures BLoC can compare events efficiently.
- Save auth_event.dart.
- Create the file lib/presentation/blocs/auth/auth_state.dart and add the following code:
part of 'auth_bloc.dart';
// All possible states the auth system can be in
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
// Initial state before any auth check
class AuthInitial extends AuthState {}
// Currently checking or signing in
class AuthLoading extends AuthState {}
// No valid session found
class Unauthenticated extends AuthState {}
// Signed in with user data and optional username
class Authenticated extends AuthState {
final dynamic user;
final String? username;
Authenticated({required this.user, this.username});
@override
List<Object?> get props => [user, username];
}
What does this code do?
- Four states form the auth state machine: AuthInitial (app just launched), AuthLoading (checking session), Authenticated (signed in), and Unauthenticated (sign-in failed).
- Authenticated holds the user object and an optional username. When username is null, the app knows to show the username entry screen.
- Save auth_state.dart.
- Now create the bloc itself at lib/presentation/blocs/auth/auth_bloc.dart with the following code:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../data/datasources/auth_remote_data_source.dart';
part 'auth_event.dart';
part 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRemoteDataSource dataSource;
AuthBloc(this.dataSource) : super(AuthInitial()) {
on<AppStarted>(_onAppStarted);
on<UpdateUsername>(_onUpdateUsername);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
// Check if user already has a session
var user = Supabase.instance.client.auth.currentUser;
if (user == null) {
// No session - sign in anonymously
await dataSource.signInAnonymously();
user = Supabase.instance.client.auth.currentUser!;
}
// Check if username exists
final username = await dataSource.getUsername(user.id);
emit(Authenticated(user: user, username: username));
} catch (e) {
emit(Unauthenticated());
}
}
Future<void> _onUpdateUsername(UpdateUsername event, Emitter<AuthState> emit) async {
final currentState = state;
if (currentState is Authenticated) {
await dataSource.updateUsername(currentState.user.id, event.username);
emit(Authenticated(user: currentState.user, username: event.username));
}
}
}
What does this code do?
- _onAppStarted first checks for an existing session. If none exists, it signs in anonymously. Then it fetches the username to decide which screen to show.
- _onUpdateUsername saves the username to the database and re-emits the Authenticated state with the new name. This triggers the UI to navigate away from the username screen.
- The part directives link the events and states files into this compilation unit.
- Save auth_bloc.dart.
Seeing red underlines in your IDE?
The part files will show errors until all three files exist and are saved. Make sure all three files (auth_bloc.dart, auth_event.dart, auth_state.dart) are saved in the same folder.
If errors persist, try restarting your IDE's Dart analysis server.
Help me fix Dart part file errors in my AuthBloc
Wire up navigation and build the username screen
You have the auth logic. Now you need to connect it to the app and build the screens the player will see. The service locator pattern (using get_it) wires dependencies together so the AuthBloc receives its data source automatically.
- Create the file lib/core/injection_container.dart and add the following code:
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../presentation/blocs/auth/auth_bloc.dart';
import '../data/datasources/auth_remote_data_source.dart';
// Global service locator instance
final sl = GetIt.instance;
void setupInjection() {
// Register Supabase client as a singleton
sl.registerLazySingleton(() => Supabase.instance.client);
// Register the auth data source
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
// Register AuthBloc as a factory (new instance each time)
sl.registerFactory(() => AuthBloc(sl()));
}
What does this code do?
- registerLazySingleton creates one instance that lives for the app's lifetime. The Supabase client and data source are shared everywhere.
- registerFactory creates a fresh AuthBloc each time it is requested. When sl() is called inside the factory, get_it automatically resolves the AuthRemoteDataSource dependency.
- Save injection_container.dart.
- Create the file lib/presentation/screens/username_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/auth/auth_bloc.dart';
class UsernameScreen extends StatefulWidget {
const UsernameScreen({super.key});
@override
State<UsernameScreen> createState() => _UsernameScreenState();
}
class _UsernameScreenState extends State<UsernameScreen> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Title prompt
const Text('Choose your username', style: TextStyle(fontSize: 24)),
const SizedBox(height: 16),
// Text input for username
TextField(
controller: _controller,
decoration: const InputDecoration(hintText: 'Username'),
),
const SizedBox(height: 16),
// Submit button dispatches UpdateUsername event
ElevatedButton(
onPressed: () {
if (_controller.text.trim().isNotEmpty) {
context.read<AuthBloc>().add(UpdateUsername(_controller.text.trim()));
}
},
child: const Text('Let\'s Play'),
),
],
),
),
),
);
}
}
What does this code do?
- The screen shows a text field and a button. When the player taps the button, it dispatches UpdateUsername to the AuthBloc.
- context.read<AuthBloc>() accesses the bloc provided higher up the widget tree. The bloc saves the username and emits a new Authenticated state, which triggers navigation.
- Save username_screen.dart.
- Create a placeholder lobby screen at lib/presentation/screens/lobby_screen.dart with the following code:
import 'package:flutter/material.dart';
// Placeholder screen - you'll build the real lobby in the next step
class LobbyScreen extends StatelessWidget {
const LobbyScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Lobby - Coming Soon')),
);
}
}
- Save lobby_screen.dart.
- Now open lib/main.dart and replace its contents with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'core/injection_container.dart';
import 'presentation/blocs/auth/auth_bloc.dart';
import 'presentation/screens/username_screen.dart';
import 'presentation/screens/lobby_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Supabase with your project credentials
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
);
// Register all dependencies
setupInjection();
runApp(const MirrorMatchApp());
}
class MirrorMatchApp extends StatelessWidget {
const MirrorMatchApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mirror Match',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: BlocProvider(
// Create AuthBloc and immediately dispatch AppStarted
create: (_) => sl<AuthBloc>()..add(AppStarted()),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is Authenticated) {
// No username yet - show username entry
if (state.username == null) return const UsernameScreen();
// Has username - go to lobby
return const LobbyScreen();
}
// Loading or unauthenticated - show spinner
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
},
),
),
);
}
}
What does this code do?
- Supabase.initialize() connects your app to your Supabase project. You will paste your real URL and anon key here.
- BlocProvider creates the AuthBloc and makes it available to all child widgets. The ..add(AppStarted()) syntax immediately dispatches the event after creation.
- BlocBuilder rebuilds the UI every time the auth state changes. It routes to UsernameScreen, LobbyScreen, or a loading spinner based on the current state.
- Replace YOUR_SUPABASE_URL with your Supabase project URL from the dashboard (found under Settings > API).
- Replace YOUR_SUPABASE_ANON_KEY with your project's anon/public key from the same page.
- Save main.dart.
Where to find your credentials
In the Supabase dashboard, click Settings in the left sidebar, then API. Your project URL is at the top and the anon key is under Project API keys. The anon key is safe to include in client-side code because RLS protects your data.
- Run the app on your physical Android device or emulator by pressing F5 in VS Code or clicking the run button in Android Studio.
You should see a loading spinner briefly, then the username entry screen with a text field and a "Let's Play" button.
- Enter a username and tap Let's Play.
The app should navigate to the "Lobby - Coming Soon" placeholder screen. This confirms your anonymous auth, profile creation, and navigation are all working.
- In the Supabase dashboard, click Table Editor in the left sidebar and select the profiles table.
You should see a row with a UUID and the username you just entered.
App stuck on the loading spinner?
- Make sure you enabled Anonymous Sign-In in your Supabase dashboard under Authentication > Providers. Without this, the signInAnonymously() call fails silently.
- Double check that your Supabase URL and anon key in main.dart are correct. A typo in either will prevent connection.
- Confirm your device or emulator has internet access.
Help me debug my Supabase anonymous auth connection
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for every file you created in this step.
import 'package:supabase_flutter/supabase_flutter.dart';
abstract class AuthRemoteDataSource {
Future<void> signInAnonymously();
Future<void> updateUsername(String userId, String username);
Future<String?> getUsername(String userId);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final SupabaseClient client;
AuthRemoteDataSourceImpl(this.client);
@override
Future<void> signInAnonymously() async {
await client.auth.signInAnonymously();
final userId = client.auth.currentUser!.id;
await client.from('profiles').upsert({'id': userId});
}
@override
Future<void> updateUsername(String userId, String username) async {
await client.from('profiles').update({'username': username}).eq('id', userId);
}
@override
Future<String?> getUsername(String userId) async {
final data = await client.from('profiles').select('username').eq('id', userId).single();
return data['username'] as String?;
}
}
part of 'auth_bloc.dart';
abstract class AuthEvent extends Equatable {
@override
List<Object?> get props => [];
}
class AppStarted extends AuthEvent {}
class UpdateUsername extends AuthEvent {
final String username;
UpdateUsername(this.username);
@override
List<Object?> get props => [username];
}
part of 'auth_bloc.dart';
abstract class AuthState extends Equatable {
@override
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class Unauthenticated extends AuthState {}
class Authenticated extends AuthState {
final dynamic user;
final String? username;
Authenticated({required this.user, this.username});
@override
List<Object?> get props => [user, username];
}
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../data/datasources/auth_remote_data_source.dart';
part 'auth_event.dart';
part 'auth_state.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRemoteDataSource dataSource;
AuthBloc(this.dataSource) : super(AuthInitial()) {
on<AppStarted>(_onAppStarted);
on<UpdateUsername>(_onUpdateUsername);
}
Future<void> _onAppStarted(AppStarted event, Emitter<AuthState> emit) async {
emit(AuthLoading());
try {
var user = Supabase.instance.client.auth.currentUser;
if (user == null) {
await dataSource.signInAnonymously();
user = Supabase.instance.client.auth.currentUser!;
}
final username = await dataSource.getUsername(user.id);
emit(Authenticated(user: user, username: username));
} catch (e) {
emit(Unauthenticated());
}
}
Future<void> _onUpdateUsername(UpdateUsername event, Emitter<AuthState> emit) async {
final currentState = state;
if (currentState is Authenticated) {
await dataSource.updateUsername(currentState.user.id, event.username);
emit(Authenticated(user: currentState.user, username: event.username));
}
}
}
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../presentation/blocs/auth/auth_bloc.dart';
import '../data/datasources/auth_remote_data_source.dart';
final sl = GetIt.instance;
void setupInjection() {
sl.registerLazySingleton(() => Supabase.instance.client);
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
sl.registerFactory(() => AuthBloc(sl()));
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/auth/auth_bloc.dart';
class UsernameScreen extends StatefulWidget {
const UsernameScreen({super.key});
@override
State<UsernameScreen> createState() => _UsernameScreenState();
}
class _UsernameScreenState extends State<UsernameScreen> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Choose your username', style: TextStyle(fontSize: 24)),
const SizedBox(height: 16),
TextField(
controller: _controller,
decoration: const InputDecoration(hintText: 'Username'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_controller.text.trim().isNotEmpty) {
context.read<AuthBloc>().add(UpdateUsername(_controller.text.trim()));
}
},
child: const Text('Let\'s Play'),
),
],
),
),
),
);
}
}
import 'package:flutter/material.dart';
class LobbyScreen extends StatelessWidget {
const LobbyScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(child: Text('Lobby - Coming Soon')),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'core/injection_container.dart';
import 'presentation/blocs/auth/auth_bloc.dart';
import 'presentation/screens/username_screen.dart';
import 'presentation/screens/lobby_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Supabase.initialize(
url: 'YOUR_SUPABASE_URL',
anonKey: 'YOUR_SUPABASE_ANON_KEY',
);
setupInjection();
runApp(const MirrorMatchApp());
}
class MirrorMatchApp extends StatelessWidget {
const MirrorMatchApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mirror Match',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: BlocProvider(
create: (_) => sl<AuthBloc>()..add(AppStarted()),
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is Authenticated) {
if (state.username == null) return const UsernameScreen();
return const LobbyScreen();
}
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
},
),
),
);
}
}
Your app now handles identity from the very first launch. Next up, you will build the real-time matchmaking system that pairs two players together using Supabase Presence.
Build Real-time Matchmaking with Presence
Your players can sign in anonymously and set a username. Now they need a way to find each other.
Multiplayer matchmaking is the process of pairing two players together before a game begins. In Mirror Match, you will use Supabase Presence to let players broadcast their availability on a shared lobby channel. When two players appear in the lobby at the same time, the app pairs them and creates a game session.
In this step, get ready to:
- Create a matchmaking data source that uses Presence and Broadcast to pair players.
- Build a MatchmakingBloc state machine that manages lobby lifecycle.
- Update the LobbyScreen with a Find Match button and waiting animation.
Create the matchmaking data source
The matchmaking data source handles the real-time logic for finding opponents. It subscribes to a Presence channel called lobby, tracks the current player, and watches for other players joining.
How does Presence-based matchmaking work?
Supabase Presence lets each connected client publish a small payload to a shared channel. Every client gets a merged view of all currently connected users via presenceState().
When a new player joins the lobby, a sync event fires on all connected clients. Each client checks: are there two players now? If yes, the player with the lexicographically smaller user ID creates the game session. This prevents both players from creating duplicate sessions.
- Create a new file at lib/data/datasources/matchmaking_remote_data_source.dart and add the following code:
import 'package:supabase_flutter/supabase_flutter.dart';
abstract class MatchmakingRemoteDataSource {
RealtimeChannel joinLobby({
required String userId,
required String username,
required void Function(String gameSessionId, String opponentId, String opponentUsername) onMatchFound,
});
Future<String> createGameSession(String player1Id, String player2Id);
}
class MatchmakingRemoteDataSourceImpl implements MatchmakingRemoteDataSource {
final SupabaseClient client;
MatchmakingRemoteDataSourceImpl(this.client);
@override
RealtimeChannel joinLobby({
required String userId,
required String username,
required void Function(String gameSessionId, String opponentId, String opponentUsername) onMatchFound,
}) {
final channel = client.channel('lobby');
channel
.onPresenceSync((payload) async {
// Check presence state for another player
final presenceState = channel.presenceState();
final others = presenceState.entries
.where((e) => e.key != userId)
.toList();
if (others.isNotEmpty) {
final opponent = others.first.value.first;
final opponentId = opponent['user_id'] as String;
final opponentUsername = opponent['username'] as String;
// Only the lexicographically smaller ID creates the session
if (userId.compareTo(opponentId) < 0) {
final sessionId = await createGameSession(userId, opponentId);
channel.sendBroadcastMessage(
event: 'match_found',
payload: {'game_session_id': sessionId, 'player1_id': userId, 'player2_id': opponentId},
);
onMatchFound(sessionId, opponentId, opponentUsername);
}
}
})
.onBroadcast(
event: 'match_found',
callback: (payload) {
// The other player receives the match notification
if (payload['player2_id'] == userId) {
final sessionId = payload['game_session_id'] as String;
final opponentId = payload['player1_id'] as String;
onMatchFound(sessionId, opponentId, 'Opponent');
}
},
)
.subscribe((status, [error]) async {
// Track presence once subscribed
if (status == RealtimeSubscribeStatus.subscribed) {
await channel.track({'user_id': userId, 'username': username});
}
});
return channel;
}
@override
Future<String> createGameSession(String player1Id, String player2Id) async {
// Insert a new game session row in Supabase
final data = await client.from('game_sessions').insert({
'player1_id': player1Id,
'player2_id': player2Id,
'status': 'waiting',
}).select('id').single();
return data['id'] as String;
}
}
What does this code do?
- joinLobby() subscribes to a Realtime channel called lobby, then uses .onPresenceSync() to detect when another player appears.
- The userId.compareTo(opponentId) < 0 check ensures only one of the two players creates the game session. This prevents race conditions where both players try to create one simultaneously.
- sendBroadcastMessage() notifies the other player about the match via Broadcast. The second player listens with .onBroadcast() and receives the game session ID.
- createGameSession() inserts a new row in the game_sessions table with both player IDs and a waiting status.
- Save matchmaking_remote_data_source.dart.
Seeing type errors on RealtimeChannel?
Make sure you have import 'package:supabase_flutter/supabase_flutter.dart'; at the top of the file. The RealtimeChannel type, RealtimeSubscribeStatus, and all presence methods come from this package.
If your IDE still shows errors, try restarting the Dart Analysis Server. help me fix type errors in my matchmaking data source
Build the MatchmakingBloc
The MatchmakingBloc manages the full matchmaking lifecycle. It transitions through three states: initial, searching, and match found.
- Create a new folder at lib/presentation/blocs/matchmaking/.
- Create matchmaking_event.dart inside that folder with the following code:
part of 'matchmaking_bloc.dart';
abstract class MatchmakingEvent extends Equatable {
@override
List<Object?> get props => [];
}
// Player taps Find Match
class JoinLobby extends MatchmakingEvent {
final String userId;
final String username;
JoinLobby({required this.userId, required this.username});
@override
List<Object?> get props => [userId, username];
}
// Player leaves the lobby
class LeaveLobby extends MatchmakingEvent {}
// An opponent was detected in Presence
class OpponentFound extends MatchmakingEvent {
final String gameSessionId;
final String opponentId;
final String opponentUsername;
OpponentFound({required this.gameSessionId, required this.opponentId, required this.opponentUsername});
@override
List<Object?> get props => [gameSessionId, opponentId, opponentUsername];
}
- Create matchmaking_state.dart in the same folder:
part of 'matchmaking_bloc.dart';
abstract class MatchmakingState extends Equatable {
@override
List<Object?> get props => [];
}
// Idle state before searching
class MatchmakingInitial extends MatchmakingState {}
// Actively looking for an opponent
class SearchingForOpponent extends MatchmakingState {}
// A match has been found
class MatchFound extends MatchmakingState {
final String gameSessionId;
final String opponentId;
final String opponentUsername;
MatchFound({required this.gameSessionId, required this.opponentId, required this.opponentUsername});
@override
List<Object?> get props => [gameSessionId, opponentId, opponentUsername];
}
What are these events and states?
- JoinLobby fires when the player taps Find Match. It carries their user ID and username.
- OpponentFound fires from the data source callback when Presence detects a second player.
- SearchingForOpponent shows the waiting animation. MatchFound triggers navigation to the game screen.
- Now create matchmaking_bloc.dart in the same folder with the bloc logic:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../data/datasources/matchmaking_remote_data_source.dart';
part 'matchmaking_event.dart';
part 'matchmaking_state.dart';
class MatchmakingBloc extends Bloc<MatchmakingEvent, MatchmakingState> {
final MatchmakingRemoteDataSource dataSource;
RealtimeChannel? _lobbyChannel;
MatchmakingBloc(this.dataSource) : super(MatchmakingInitial()) {
on<JoinLobby>(_onJoinLobby);
on<LeaveLobby>(_onLeaveLobby);
on<OpponentFound>(_onOpponentFound);
}
Future<void> _onJoinLobby(JoinLobby event, Emitter<MatchmakingState> emit) async {
emit(SearchingForOpponent());
// Join the lobby and listen for matches via the data source
_lobbyChannel = dataSource.joinLobby(
userId: event.userId,
username: event.username,
onMatchFound: (sessionId, opponentId, opponentUsername) {
add(OpponentFound(
gameSessionId: sessionId,
opponentId: opponentId,
opponentUsername: opponentUsername,
));
},
);
}
Future<void> _onLeaveLobby(LeaveLobby event, Emitter<MatchmakingState> emit) async {
// Clean up the channel subscription
await _lobbyChannel?.unsubscribe();
emit(MatchmakingInitial());
}
void _onOpponentFound(OpponentFound event, Emitter<MatchmakingState> emit) {
emit(MatchFound(
gameSessionId: event.gameSessionId,
opponentId: event.opponentId,
opponentUsername: event.opponentUsername,
));
}
}
What does the MatchmakingBloc do?
When JoinLobby is dispatched, the bloc emits SearchingForOpponent and calls the data source to join the lobby. The data source handles Presence logic and calls back when a match is found.
The callback dispatches OpponentFound back to the bloc, which emits MatchFound containing the game session ID and opponent details. The UI listens for this state to navigate to the game screen.
- Save all three files in the matchmaking/ folder.
Now register the new data source and bloc in the dependency injection container.
- Open lib/core/injection_container.dart.
- Find this line at the top:
import '../data/datasources/auth_remote_data_source.dart';
- Add these two imports directly below it:
import '../presentation/blocs/matchmaking/matchmaking_bloc.dart';
import '../data/datasources/matchmaking_remote_data_source.dart';
- Inside the setupInjection() function, find this line:
sl.registerFactory(() => AuthBloc(sl()));
- Add the matchmaking registrations above that line, so the full function looks like this:
void setupInjection() {
sl.registerLazySingleton(() => Supabase.instance.client);
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<MatchmakingRemoteDataSource>(
() => MatchmakingRemoteDataSourceImpl(sl()),
);
sl.registerFactory(() => AuthBloc(sl()));
sl.registerFactory(() => MatchmakingBloc(sl()));
}
- Save injection_container.dart.
Seeing 'MatchmakingRemoteDataSource is not registered'?
Make sure the registerLazySingleton<MatchmakingRemoteDataSource> line appears before the registerFactory(() => MatchmakingBloc(sl())) line. get_it resolves dependencies at call time, so the data source must be registered before the bloc that uses it.
help me debug my get_it registration order for MatchmakingBloc
Build the lobby UI and game screen placeholder
The lobby screen needs a Find Match button that dispatches JoinLobby, a loading spinner while searching, and navigation to the game screen when a match is found.
- Create a new file at lib/presentation/screens/game_screen.dart with this placeholder widget:
import 'package:flutter/material.dart';
class GameScreen extends StatelessWidget {
final String gameSessionId;
final String opponentUsername;
const GameScreen({
super.key,
required this.gameSessionId,
required this.opponentUsername,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
// Placeholder until we build the full game loop
child: Text('Game vs $opponentUsername - Coming Soon'),
),
);
}
}
- Save game_screen.dart.
- Now open lib/presentation/screens/lobby_screen.dart and replace the entire file with the following:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../core/injection_container.dart';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/matchmaking/matchmaking_bloc.dart';
import 'game_screen.dart';
class LobbyScreen extends StatelessWidget {
const LobbyScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<MatchmakingBloc>(),
child: BlocConsumer<MatchmakingBloc, MatchmakingState>(
listener: (context, state) {
// Navigate to game screen when match is found
if (state is MatchFound) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => GameScreen(
gameSessionId: state.gameSessionId,
opponentUsername: state.opponentUsername,
),
),
);
}
},
builder: (context, state) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Mirror Match', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
const SizedBox(height: 32),
if (state is SearchingForOpponent) ...[
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('Searching for opponent...'),
] else ...[
ElevatedButton(
onPressed: () {
// Read current auth state and dispatch JoinLobby
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
context.read<MatchmakingBloc>().add(JoinLobby(
userId: authState.user.id,
username: authState.username ?? 'Player',
));
}
},
child: const Text('Find Match'),
),
],
],
),
),
);
},
),
);
}
}
What does this UI do?
- BlocProvider creates the MatchmakingBloc from the service locator.
- BlocConsumer splits responsibilities: the listener handles side effects (navigation), while the builder renders the UI based on the current state.
- When SearchingForOpponent is active, the button is replaced by a CircularProgressIndicator and status text.
- When MatchFound is emitted, the listener navigates to GameScreen with the session ID and opponent username.
- Save lobby_screen.dart.
- Run the app on your device or emulator.
- You should see the lobby screen with a Mirror Match title and a Find Match button.
- Tap Find Match. The button should disappear and a spinner with "Searching for opponent..." text should appear.
Test with two devices
To fully test matchmaking, run one instance on your physical Android device and a second on an Android emulator. Both connect to the same Supabase project, so when both tap Find Match, Presence detects two players and creates the game session.
Tapping Find Match does nothing?
Make sure your LobbyScreen is wrapped in a parent BlocProvider<AuthBloc> from main.dart. The context.read<AuthBloc>().state call needs access to the AuthBloc provided higher in the widget tree.
Also verify that Realtime is enabled on your Supabase project by checking the game_sessions table is published (you did this in Step 2).
help me debug why Find Match is not working in my lobby screen
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or modified in this step.
import 'package:supabase_flutter/supabase_flutter.dart';
abstract class MatchmakingRemoteDataSource {
RealtimeChannel joinLobby({
required String userId,
required String username,
required void Function(String gameSessionId, String opponentId, String opponentUsername) onMatchFound,
});
Future<String> createGameSession(String player1Id, String player2Id);
}
class MatchmakingRemoteDataSourceImpl implements MatchmakingRemoteDataSource {
final SupabaseClient client;
MatchmakingRemoteDataSourceImpl(this.client);
@override
RealtimeChannel joinLobby({
required String userId,
required String username,
required void Function(String gameSessionId, String opponentId, String opponentUsername) onMatchFound,
}) {
final channel = client.channel('lobby');
channel
.onPresenceSync((payload) async {
final presenceState = channel.presenceState();
final others = presenceState.entries
.where((e) => e.key != userId)
.toList();
if (others.isNotEmpty) {
final opponent = others.first.value.first;
final opponentId = opponent['user_id'] as String;
final opponentUsername = opponent['username'] as String;
if (userId.compareTo(opponentId) < 0) {
final sessionId = await createGameSession(userId, opponentId);
channel.sendBroadcastMessage(
event: 'match_found',
payload: {'game_session_id': sessionId, 'player1_id': userId, 'player2_id': opponentId},
);
onMatchFound(sessionId, opponentId, opponentUsername);
}
}
})
.onBroadcast(
event: 'match_found',
callback: (payload) {
if (payload['player2_id'] == userId) {
final sessionId = payload['game_session_id'] as String;
final opponentId = payload['player1_id'] as String;
onMatchFound(sessionId, opponentId, 'Opponent');
}
},
)
.subscribe((status, [error]) async {
if (status == RealtimeSubscribeStatus.subscribed) {
await channel.track({'user_id': userId, 'username': username});
}
});
return channel;
}
@override
Future<String> createGameSession(String player1Id, String player2Id) async {
final data = await client.from('game_sessions').insert({
'player1_id': player1Id,
'player2_id': player2Id,
'status': 'waiting',
}).select('id').single();
return data['id'] as String;
}
}
part of 'matchmaking_bloc.dart';
abstract class MatchmakingEvent extends Equatable {
@override
List<Object?> get props => [];
}
class JoinLobby extends MatchmakingEvent {
final String userId;
final String username;
JoinLobby({required this.userId, required this.username});
@override
List<Object?> get props => [userId, username];
}
class LeaveLobby extends MatchmakingEvent {}
class OpponentFound extends MatchmakingEvent {
final String gameSessionId;
final String opponentId;
final String opponentUsername;
OpponentFound({required this.gameSessionId, required this.opponentId, required this.opponentUsername});
@override
List<Object?> get props => [gameSessionId, opponentId, opponentUsername];
}
part of 'matchmaking_bloc.dart';
abstract class MatchmakingState extends Equatable {
@override
List<Object?> get props => [];
}
class MatchmakingInitial extends MatchmakingState {}
class SearchingForOpponent extends MatchmakingState {}
class MatchFound extends MatchmakingState {
final String gameSessionId;
final String opponentId;
final String opponentUsername;
MatchFound({required this.gameSessionId, required this.opponentId, required this.opponentUsername});
@override
List<Object?> get props => [gameSessionId, opponentId, opponentUsername];
}
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../data/datasources/matchmaking_remote_data_source.dart';
part 'matchmaking_event.dart';
part 'matchmaking_state.dart';
class MatchmakingBloc extends Bloc<MatchmakingEvent, MatchmakingState> {
final MatchmakingRemoteDataSource dataSource;
RealtimeChannel? _lobbyChannel;
MatchmakingBloc(this.dataSource) : super(MatchmakingInitial()) {
on<JoinLobby>(_onJoinLobby);
on<LeaveLobby>(_onLeaveLobby);
on<OpponentFound>(_onOpponentFound);
}
Future<void> _onJoinLobby(JoinLobby event, Emitter<MatchmakingState> emit) async {
emit(SearchingForOpponent());
_lobbyChannel = dataSource.joinLobby(
userId: event.userId,
username: event.username,
onMatchFound: (sessionId, opponentId, opponentUsername) {
add(OpponentFound(
gameSessionId: sessionId,
opponentId: opponentId,
opponentUsername: opponentUsername,
));
},
);
}
Future<void> _onLeaveLobby(LeaveLobby event, Emitter<MatchmakingState> emit) async {
await _lobbyChannel?.unsubscribe();
emit(MatchmakingInitial());
}
void _onOpponentFound(OpponentFound event, Emitter<MatchmakingState> emit) {
emit(MatchFound(
gameSessionId: event.gameSessionId,
opponentId: event.opponentId,
opponentUsername: event.opponentUsername,
));
}
}
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../presentation/blocs/auth/auth_bloc.dart';
import '../presentation/blocs/matchmaking/matchmaking_bloc.dart';
import '../data/datasources/auth_remote_data_source.dart';
import '../data/datasources/matchmaking_remote_data_source.dart';
final sl = GetIt.instance;
void setupInjection() {
sl.registerLazySingleton(() => Supabase.instance.client);
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<MatchmakingRemoteDataSource>(
() => MatchmakingRemoteDataSourceImpl(sl()),
);
sl.registerFactory(() => AuthBloc(sl()));
sl.registerFactory(() => MatchmakingBloc(sl()));
}
import 'package:flutter/material.dart';
class GameScreen extends StatelessWidget {
final String gameSessionId;
final String opponentUsername;
const GameScreen({
super.key,
required this.gameSessionId,
required this.opponentUsername,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text('Game vs $opponentUsername - Coming Soon'),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../core/injection_container.dart';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/matchmaking/matchmaking_bloc.dart';
import 'game_screen.dart';
class LobbyScreen extends StatelessWidget {
const LobbyScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => sl<MatchmakingBloc>(),
child: BlocConsumer<MatchmakingBloc, MatchmakingState>(
listener: (context, state) {
if (state is MatchFound) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => GameScreen(
gameSessionId: state.gameSessionId,
opponentUsername: state.opponentUsername,
),
),
);
}
},
builder: (context, state) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Mirror Match', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
const SizedBox(height: 32),
if (state is SearchingForOpponent) ...[
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('Searching for opponent...'),
] else ...[
ElevatedButton(
onPressed: () {
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
context.read<MatchmakingBloc>().add(JoinLobby(
userId: authState.user.id,
username: authState.username ?? 'Player',
));
}
},
child: const Text('Find Match'),
),
],
],
),
),
);
},
),
);
}
}
Your matchmaking system is wired up. Next up, you will build the Edge Function that validates player choices server-side and prevents cheating during actual gameplay.
Deploy the Edge Function for Choice Validation
Right now, a player could technically read their opponent's choice from the database before submitting their own. If both players write directly to the rounds table, there is nothing stopping a malicious client from peeking first. You need a trusted middleman that accepts choices blindly and only reveals results once both are in.
That middleman is a Supabase Edge Function. It runs server-side TypeScript on Deno, receives each player's choice, records it with a server timestamp, and only broadcasts the reveal once both players have submitted. This is your anti-cheat layer.
In this step, get ready to:
- Create and deploy the validate-choice Edge Function with server-side game logic.
- Wire the Edge Function into the Flutter app via a GameRemoteDataSource.
- Test the Edge Function locally and through the Supabase Dashboard.
Create the validate-choice Edge Function
Edge Functions are TypeScript functions that run on Supabase's servers. Your validate-choice function will be the only way choices enter the database, ensuring no one can cheat.
Why an Edge Function instead of writing directly to the database?
If players write directly to the rounds table, a malicious client could read the opponent's choice before submitting their own. The client could also fake timestamps to make it look like they submitted first.
The Edge Function is the single source of truth for timing. It records server timestamps, atomically checks whether both players have submitted, and only then triggers the reveal broadcast. No client ever sees the opponent's choice until the server says both are in.
- Open your terminal inside the mirror_match project folder.
- Initialize the Supabase local directory (if you have not already) by running:
supabase init
Already have a supabase folder?
If you already have a supabase/ folder from earlier steps, this command will tell you it already exists. That is fine. Move on to the next command.
- Link the CLI to your cloud Supabase project by running:
supabase link --project-ref [[SUPABASE_PROJECT_REF="your-project-reference-id"]]
Where do I find my project reference?
In the Supabase Dashboard, click Project Settings (the gear icon in the sidebar). Your project reference ID is the short string shown under General. It looks something like abcdefghijklmnopqrst.
- Scaffold the Edge Function by running:
supabase functions new validate-choice
- Confirm the file was created by running:
ls supabase/functions/validate-choice/
You should see index.ts listed in the output.
Command not found?
Make sure the Supabase CLI is installed via Scoop. Run supabase --version to confirm. If it is not recognized, try opening a new terminal window so Scoop's PATH changes take effect.
Help me troubleshoot my Supabase CLI installation on Windows with Scoop.
Now you need to replace the boilerplate in index.ts with your game validation logic. This is TypeScript, not Dart.
- Open supabase/functions/validate-choice/index.ts in your code editor.
- Delete all the boilerplate content.
- Paste the following code that handles auth validation, choice recording, and round reveals:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
serve(async (req) => {
try {
// Validate the JWT from the Authorization header
const authHeader = req.headers.get('Authorization');
if (!authHeader) return new Response('Unauthorized', { status: 401 });
// Create a service-role client (bypasses RLS for server-side writes)
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
);
// Create a user-scoped client to verify the JWT
const userClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{ global: { headers: { Authorization: authHeader } } }
);
const { data: { user }, error: userError } = await userClient.auth.getUser();
if (userError || !user) return new Response('Unauthorized', { status: 401 });
// Parse the request body
const { gameSessionId, choice, roundNumber } = await req.json();
// Look up the game session to determine player roles
const { data: session } = await supabase
.from('game_sessions')
.select('*')
.eq('id', gameSessionId)
.single();
if (!session) return new Response('Session not found', { status: 404 });
// Verify the caller is a participant in this game
const isPlayer1 = session.player1_id === user.id;
const isPlayer2 = session.player2_id === user.id;
if (!isPlayer1 && !isPlayer2) return new Response('Forbidden', { status: 403 });
// Determine which column to write the choice into
const choiceField = isPlayer1 ? 'player1_choice' : 'player2_choice';
// Check if a round row already exists (the other player may have submitted first)
const { data: existingRound } = await supabase
.from('rounds')
.select('*')
.eq('game_session_id', gameSessionId)
.eq('round_number', roundNumber)
.maybeSingle();
let round;
if (existingRound) {
// Update the existing round with this player's choice
const { data } = await supabase
.from('rounds')
.update({ [choiceField]: choice })
.eq('id', existingRound.id)
.select()
.single();
round = data;
} else {
// Create a new round row with this player's choice
const { data } = await supabase
.from('rounds')
.insert({
game_session_id: gameSessionId,
round_number: roundNumber,
[choiceField]: choice,
})
.select()
.single();
round = data;
}
// Check if both players have now submitted
const bothSubmitted = round.player1_choice && round.player2_choice;
if (bothSubmitted) {
// Determine if choices match (sync!)
const isSync = round.player1_choice === round.player2_choice;
await supabase
.from('rounds')
.update({ is_sync: isSync, revealed_at: new Date().toISOString() })
.eq('id', round.id);
// Update the game session score and current round
if (isSync) {
await supabase
.from('game_sessions')
.update({ sync_score: session.sync_score + 1, current_round: roundNumber })
.eq('id', gameSessionId);
} else {
await supabase
.from('game_sessions')
.update({ current_round: roundNumber })
.eq('id', gameSessionId);
}
// Broadcast the round reveal to both players
const channel = supabase.channel(`game:${gameSessionId}`);
await channel.send({
type: 'broadcast',
event: 'round_reveal',
payload: {
roundNumber,
player1Choice: round.player1_choice,
player2Choice: round.player2_choice,
isSync,
},
});
// If this was the final round, mark the game as completed
if (roundNumber >= session.total_rounds) {
await supabase
.from('game_sessions')
.update({ status: 'completed', completed_at: new Date().toISOString() })
.eq('id', gameSessionId);
}
} else {
// Only one player has submitted so far - notify the other player
const channel = supabase.channel(`game:${gameSessionId}`);
await channel.send({
type: 'broadcast',
event: 'player_submitted',
payload: { playerId: user.id, roundNumber },
});
}
return new Response(JSON.stringify({ success: true, bothSubmitted }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});
What does this code do?
This function handles the entire choice submission lifecycle:
- Auth validation: It verifies the JWT from the request header using a user-scoped client. A service-role client handles the actual database writes (bypassing RLS so it can write for both players).
- Choice recording: It checks whether a round row already exists (meaning the other player submitted first). If so, it updates the existing row. If not, it creates a new one.
- Reveal logic: Once both choices are in, it calculates whether they match (isSync), updates the score, and broadcasts a round_reveal event to both players via Realtime Broadcast.
- Waiting state: If only one player has submitted, it broadcasts a player_submitted event so the UI can show a waiting indicator.
- Save index.ts.
Why TypeScript and not Dart?
Supabase Edge Functions only support TypeScript running on the Deno runtime. Dart Edge exists but is experimental and not actively maintained. Writing a small TypeScript file here is a valuable addition to your portfolio because it demonstrates polyglot ability.
Now deploy the function to your Supabase cloud project.
- Deploy the Edge Function by running:
supabase functions deploy validate-choice
You should see output confirming the function was deployed successfully, ending with a URL like https://<project-ref>.supabase.co/functions/v1/validate-choice.
Deploy failing with Docker error?
Make sure Docker Desktop is running. The CLI uses Docker for bundling. If Docker is not available, you can deploy without it by adding the --use-api flag: supabase functions deploy validate-choice --use-api.
Help me troubleshoot deploying my Supabase Edge Function.
Wire the Edge Function into the Flutter app
The Flutter app needs a way to call your new Edge Function. Following clean architecture, you will create a dedicated data source that handles all game-related remote calls.
- Create a new file at lib/data/datasources/game_remote_data_source.dart.
- Paste the following code that defines the GameRemoteDataSource with a method to submit choices and a method to subscribe to game events:
import 'package:supabase_flutter/supabase_flutter.dart';
abstract class GameRemoteDataSource {
Future<void> submitChoice({
required String gameSessionId,
required String choice,
required int roundNumber,
});
RealtimeChannel subscribeToGame({
required String gameSessionId,
required void Function(Map<String, dynamic>) onRoundReveal,
required void Function(Map<String, dynamic>) onPlayerSubmitted,
});
}
class GameRemoteDataSourceImpl implements GameRemoteDataSource {
final SupabaseClient client;
GameRemoteDataSourceImpl(this.client);
@override
Future<void> submitChoice({
required String gameSessionId,
required String choice,
required int roundNumber,
}) async {
// Call the Edge Function instead of writing directly to the DB
await client.functions.invoke(
'validate-choice',
body: {
'gameSessionId': gameSessionId,
'choice': choice,
'roundNumber': roundNumber,
},
);
}
@override
RealtimeChannel subscribeToGame({
required String gameSessionId,
required void Function(Map<String, dynamic>) onRoundReveal,
required void Function(Map<String, dynamic>) onPlayerSubmitted,
}) {
// Listen for broadcast events from the Edge Function
return client
.channel('game:$gameSessionId')
.onBroadcast(event: 'round_reveal', callback: (payload) => onRoundReveal(payload))
.onBroadcast(event: 'player_submitted', callback: (payload) => onPlayerSubmitted(payload))
.subscribe();
}
}
What does this code do?
- submitChoice() calls your Edge Function via client.functions.invoke(). The Supabase client automatically attaches the user's JWT in the Authorization header.
- subscribeToGame() subscribes to a Realtime Broadcast channel named game:<sessionId>. This is the same channel the Edge Function broadcasts on when a round is revealed or a player submits.
Now register the new data source in your dependency injection container.
- Open lib/core/injection_container.dart.
- Find this line near the top of the imports:
import '../data/datasources/matchmaking_remote_data_source.dart';
- Add this import directly below it:
import '../data/datasources/game_remote_data_source.dart';
- Inside the setupInjection() function, find this registration:
sl.registerLazySingleton<MatchmakingRemoteDataSource>(
() => MatchmakingRemoteDataSourceImpl(sl()),
);
- Add the GameRemoteDataSource registration directly below it:
sl.registerLazySingleton<GameRemoteDataSource>(
() => GameRemoteDataSourceImpl(sl()),
);
- Save both game_remote_data_source.dart and injection_container.dart.
Seeing import errors in injection_container.dart?
Make sure the file path in your import matches exactly: ../data/datasources/game_remote_data_source.dart. The file must exist at lib/data/datasources/game_remote_data_source.dart. Check for typos in both the filename and the import path.
Help me fix my import errors in injection_container.dart.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the full injection_container.dart after your changes:
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../presentation/blocs/auth/auth_bloc.dart';
import '../presentation/blocs/matchmaking/matchmaking_bloc.dart';
import '../data/datasources/auth_remote_data_source.dart';
import '../data/datasources/matchmaking_remote_data_source.dart';
import '../data/datasources/game_remote_data_source.dart';
final sl = GetIt.instance;
void setupInjection() {
sl.registerLazySingleton(() => Supabase.instance.client);
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<MatchmakingRemoteDataSource>(
() => MatchmakingRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<GameRemoteDataSource>(
() => GameRemoteDataSourceImpl(sl()),
);
sl.registerFactory(() => AuthBloc(sl()));
sl.registerFactory(() => MatchmakingBloc(sl()));
}
And here is the full game_remote_data_source.dart:
import 'package:supabase_flutter/supabase_flutter.dart';
abstract class GameRemoteDataSource {
Future<void> submitChoice({
required String gameSessionId,
required String choice,
required int roundNumber,
});
RealtimeChannel subscribeToGame({
required String gameSessionId,
required void Function(Map<String, dynamic>) onRoundReveal,
required void Function(Map<String, dynamic>) onPlayerSubmitted,
});
}
class GameRemoteDataSourceImpl implements GameRemoteDataSource {
final SupabaseClient client;
GameRemoteDataSourceImpl(this.client);
@override
Future<void> submitChoice({
required String gameSessionId,
required String choice,
required int roundNumber,
}) async {
await client.functions.invoke(
'validate-choice',
body: {
'gameSessionId': gameSessionId,
'choice': choice,
'roundNumber': roundNumber,
},
);
}
@override
RealtimeChannel subscribeToGame({
required String gameSessionId,
required void Function(Map<String, dynamic>) onRoundReveal,
required void Function(Map<String, dynamic>) onPlayerSubmitted,
}) {
return client
.channel('game:$gameSessionId')
.onBroadcast(event: 'round_reveal', callback: (payload) => onRoundReveal(payload))
.onBroadcast(event: 'player_submitted', callback: (payload) => onPlayerSubmitted(payload))
.subscribe();
}
}
Test the Edge Function locally and via the Dashboard
Before relying on the function in the game loop, you want to confirm it runs without errors. You can test locally with Docker or use the built-in tester in the Supabase Dashboard.
- Make sure Docker Desktop is running.
- Start the local Edge Functions server by running:
supabase functions serve
You should see output indicating the function is served at http://localhost:54321/functions/v1/validate-choice.
What does supabase functions serve do?
This command spins up a local Deno runtime in Docker that mimics the production Edge Function environment. It automatically provides the SUPABASE_URL, SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY environment variables from your linked project.
- Press Ctrl+C to stop the local server once you have confirmed it starts successfully.
You can also verify the deployed function works using the Dashboard tester.
- Open your Supabase project in the Dashboard.
- Click Edge Functions in the left sidebar.
- Click on validate-choice in the function list.
- You should see the function details page showing its URL, deployment status, and invocation logs.
Testing with the Dashboard
The Dashboard function page lets you send test requests directly. You will not be able to fully test this function from the Dashboard alone because it requires a valid JWT from an authenticated user. The real end-to-end test will happen in the next step when the Flutter app calls the function during gameplay.
Function not appearing in the Dashboard?
Make sure the deploy command completed without errors. If you see a stale list, try refreshing the page. Also confirm you deployed to the correct project by checking that your supabase link project reference matches the Dashboard project.
Help me figure out why my deployed Edge Function is not showing up.
Your server-side anti-cheat layer is live. In the next step, you will build the GameBloc state machine that orchestrates the full round lifecycle, calling this Edge Function every time a player makes a choice and listening for the broadcast reveals.
Build the Game Loop and Real-time Broadcast
Your Edge Function knows how to validate choices and broadcast results. Your matchmaking system pairs players together. Now it is time to build the beating heart of Mirror Match: the game loop itself.
The game loop is a state machine. It cycles through countdown, choice, waiting, reveal, and score update for 20 rounds. BLoC is perfect for this because each transition is explicit and testable.
In this step, get ready to:
- Create the GameBloc state machine with events and states for the full round lifecycle.
- Wire up Supabase Realtime Broadcast listeners for round reveals and opponent submissions.
- Build the game UI with countdown, choice buttons, waiting animation, and reveal display.
Define the game events and states
A BLoC state machine needs three things: events (what can happen), states (what the UI shows), and the bloc itself (how events transform into states). You will start with events and states.
- Create a new folder at lib/presentation/blocs/game/.
- Create a new file called game_event.dart inside that folder with the following code:
part of 'game_bloc.dart';
abstract class GameEvent extends Equatable {
@override
List<Object?> get props => [];
}
class StartGame extends GameEvent {}
class NextRound extends GameEvent {}
class GameOver extends GameEvent {}
class SubmitChoice extends GameEvent {
final String choice;
SubmitChoice(this.choice);
@override
List<Object?> get props => [choice];
}
class RoundRevealed extends GameEvent {
final int roundNumber;
final String player1Choice;
final String player2Choice;
final bool isSync;
RoundRevealed({required this.roundNumber, required this.player1Choice, required this.player2Choice, required this.isSync});
@override
List<Object?> get props => [roundNumber, player1Choice, player2Choice, isSync];
}
class PlayerSubmittedEvent extends GameEvent {
final String playerId;
PlayerSubmittedEvent({required this.playerId});
@override
List<Object?> get props => [playerId];
}
What does this code do?
Each event class represents something that can happen during a game.
- StartGame kicks off the countdown and subscribes to the broadcast channel.
- SubmitChoice fires when the player taps a choice button, carrying the chosen option.
- RoundRevealed arrives from the broadcast when both players have submitted and the server has compared choices.
- PlayerSubmittedEvent notifies that the opponent has submitted (for potential UI feedback).
- NextRound and GameOver handle transitions between rounds and ending the game.
- Save game_event.dart.
- Now create game_state.dart in the same folder with the following code:
part of 'game_bloc.dart';
abstract class GameState extends Equatable {
@override
List<Object?> get props => [];
}
class GameInitial extends GameState {}
class CountdownState extends GameState {
final int secondsLeft;
CountdownState({required this.secondsLeft});
@override
List<Object?> get props => [secondsLeft];
}
class ChoosingState extends GameState {
final int roundNumber;
final int currentSyncScore;
final List<String> options;
ChoosingState({required this.roundNumber, required this.currentSyncScore, required this.options});
@override
List<Object?> get props => [roundNumber, currentSyncScore, options];
}
class WaitingForOpponentState extends GameState {
final int roundNumber;
final String myChoice;
WaitingForOpponentState({required this.roundNumber, required this.myChoice});
@override
List<Object?> get props => [roundNumber, myChoice];
}
class RevealState extends GameState {
final int roundNumber;
final String player1Choice;
final String player2Choice;
final bool isSync;
final int currentSyncScore;
RevealState({required this.roundNumber, required this.player1Choice, required this.player2Choice, required this.isSync, required this.currentSyncScore});
@override
List<Object?> get props => [roundNumber, player1Choice, player2Choice, isSync, currentSyncScore];
}
class GameOverState extends GameState {
final int finalSyncScore;
final int totalRounds;
final List<Map<String, dynamic>> rounds;
GameOverState({required this.finalSyncScore, required this.totalRounds, required this.rounds});
@override
List<Object?> get props => [finalSyncScore, totalRounds, rounds];
}
What does this code do?
Each state class represents what the game screen should display at a given moment.
- CountdownState shows a 3-2-1 countdown before the round begins.
- ChoosingState presents the choice buttons for the current round, along with the round number and running score.
- WaitingForOpponentState shows a loading indicator after the player submits, while waiting for the opponent.
- RevealState displays both choices side-by-side with a sync or no-sync indicator.
- GameOverState carries the final score and all round data for the results screen.
- Save game_state.dart.
Seeing red squiggles in the event and state files?
Both files use part of 'game_bloc.dart' which means they expect the parent file to exist. The errors will disappear once you create game_bloc.dart in the next substep.
Still stuck? Help me fix BLoC part-file errors in my Flutter project.
Implement the GameBloc state machine
The GameBloc is where all the game logic lives. It subscribes to Supabase Realtime Broadcast events, manages the countdown timer, and orchestrates state transitions across 20 rounds.
- Create game_bloc.dart in the same lib/presentation/blocs/game/ folder. Start with the imports, round options, and class setup:
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../data/datasources/game_remote_data_source.dart';
part 'game_event.dart';
part 'game_state.dart';
// Round option categories - cycled through each round
const kRoundOptions = {
'direction': ['UP', 'DOWN', 'LEFT', 'RIGHT'],
'color': ['RED', 'BLUE', 'GREEN', 'YELLOW'],
'number': ['1', '2', '3', '4'],
'swipe': ['SWIPE_UP', 'SWIPE_DOWN', 'SWIPE_LEFT', 'SWIPE_RIGHT'],
};
// Returns the choice options for a given round number
List<String> getOptionsForRound(int roundNumber) {
final categories = kRoundOptions.values.toList();
return categories[roundNumber % categories.length];
}
class GameBloc extends Bloc<GameEvent, GameState> {
final GameRemoteDataSource dataSource;
final String gameSessionId;
final String currentUserId;
RealtimeChannel? _gameChannel;
Timer? _countdownTimer;
int _currentRound = 1;
int _syncScore = 0;
final List<Map<String, dynamic>> _rounds = [];
GameBloc({
required this.dataSource,
required this.gameSessionId,
required this.currentUserId,
}) : super(GameInitial()) {
on<StartGame>(_onStartGame);
on<SubmitChoice>(_onSubmitChoice);
on<RoundRevealed>(_onRoundRevealed);
on<PlayerSubmittedEvent>(_onPlayerSubmitted);
on<NextRound>(_onNextRound);
on<GameOver>(_onGameOver);
}
What does this code do?
The top section defines the round option categories. Each round cycles through directions, colors, numbers, and swipe directions using the modulo operator.
The GameBloc class holds references to the broadcast channel, a countdown timer, the current round number, and a running sync score. The constructor registers a handler for each event type.
- Now add the event handlers below the constructor, still inside the GameBloc class:
// Subscribe to broadcast and start the countdown
void _onStartGame(StartGame event, Emitter<GameState> emit) {
_gameChannel = dataSource.subscribeToGame(
gameSessionId: gameSessionId,
onRoundReveal: (payload) => add(RoundRevealed(
roundNumber: payload['roundNumber'] as int,
player1Choice: payload['player1Choice'] as String,
player2Choice: payload['player2Choice'] as String,
isSync: payload['isSync'] as bool,
)),
onPlayerSubmitted: (payload) => add(PlayerSubmittedEvent(
playerId: payload['playerId'] as String,
)),
);
_startCountdown(emit);
}
// 3-2-1 countdown before round begins
void _startCountdown(Emitter<GameState> emit) {
int seconds = 3;
emit(CountdownState(secondsLeft: seconds));
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
seconds--;
if (seconds <= 0) {
timer.cancel();
add(NextRound());
} else {
emit(CountdownState(secondsLeft: seconds));
}
});
}
// Transition to the choosing state for the current round
void _onNextRound(NextRound event, Emitter<GameState> emit) {
emit(ChoosingState(
roundNumber: _currentRound,
currentSyncScore: _syncScore,
options: getOptionsForRound(_currentRound),
));
}
// Submit choice to the Edge Function and wait for opponent
Future<void> _onSubmitChoice(SubmitChoice event, Emitter<GameState> emit) async {
emit(WaitingForOpponentState(
roundNumber: _currentRound,
myChoice: event.choice,
));
await dataSource.submitChoice(
gameSessionId: gameSessionId,
choice: event.choice,
roundNumber: _currentRound,
);
}
// Placeholder for opponent-submitted feedback
void _onPlayerSubmitted(PlayerSubmittedEvent event, Emitter<GameState> emit) {}
// Handle the reveal broadcast from the server
void _onRoundRevealed(RoundRevealed event, Emitter<GameState> emit) {
if (event.isSync) _syncScore++;
_rounds.add({
'roundNumber': event.roundNumber,
'myChoice': currentUserId == event.player1Choice ? event.player1Choice : event.player2Choice,
'opponentChoice': currentUserId == event.player1Choice ? event.player2Choice : event.player1Choice,
'isSync': event.isSync,
});
emit(RevealState(
roundNumber: event.roundNumber,
player1Choice: event.player1Choice,
player2Choice: event.player2Choice,
isSync: event.isSync,
currentSyncScore: _syncScore,
));
// Wait 2 seconds for the reveal to display, then advance
Future.delayed(const Duration(seconds: 2), () {
if (_currentRound >= 20) {
add(GameOver());
} else {
_currentRound++;
add(NextRound());
}
});
}
// Emit final state with all round data
void _onGameOver(GameOver event, Emitter<GameState> emit) {
emit(GameOverState(
finalSyncScore: _syncScore,
totalRounds: 20,
rounds: List.from(_rounds),
));
}
@override
Future<void> close() {
_countdownTimer?.cancel();
_gameChannel?.unsubscribe();
return super.close();
}
}
What does this code do?
- _onStartGame subscribes to the broadcast channel. When the Edge Function sends a round_reveal or player_submitted event, it is converted into a BLoC event.
- _startCountdown emits countdown states every second (3, 2, 1) then triggers NextRound to show the choice buttons.
- _onSubmitChoice immediately shows the waiting state, then calls the Edge Function via the data source.
- _onRoundRevealed updates the sync score, records the round, and waits 2 seconds before advancing to the next round or ending the game.
- close() cleans up the timer and unsubscribes from the channel to prevent memory leaks.
- Save game_bloc.dart.
- Check that the red squiggles in game_event.dart and game_state.dart have disappeared now that the parent file exists.
Still seeing errors in the BLoC files?
Make sure all three files are in the same folder: lib/presentation/blocs/game/. The part and part of directives are path-sensitive.
Also verify that game_bloc.dart contains both part 'game_event.dart'; and part 'game_state.dart'; near the top.
Need more help? Help me debug part file errors in my Flutter BLoC setup.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in the game folder.
ⓧ I'd like to double check the full code
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../../data/datasources/game_remote_data_source.dart';
part 'game_event.dart';
part 'game_state.dart';
const kRoundOptions = {
'direction': ['UP', 'DOWN', 'LEFT', 'RIGHT'],
'color': ['RED', 'BLUE', 'GREEN', 'YELLOW'],
'number': ['1', '2', '3', '4'],
'swipe': ['SWIPE_UP', 'SWIPE_DOWN', 'SWIPE_LEFT', 'SWIPE_RIGHT'],
};
List<String> getOptionsForRound(int roundNumber) {
final categories = kRoundOptions.values.toList();
return categories[roundNumber % categories.length];
}
class GameBloc extends Bloc<GameEvent, GameState> {
final GameRemoteDataSource dataSource;
final String gameSessionId;
final String currentUserId;
RealtimeChannel? _gameChannel;
Timer? _countdownTimer;
int _currentRound = 1;
int _syncScore = 0;
final List<Map<String, dynamic>> _rounds = [];
GameBloc({
required this.dataSource,
required this.gameSessionId,
required this.currentUserId,
}) : super(GameInitial()) {
on<StartGame>(_onStartGame);
on<SubmitChoice>(_onSubmitChoice);
on<RoundRevealed>(_onRoundRevealed);
on<PlayerSubmittedEvent>(_onPlayerSubmitted);
on<NextRound>(_onNextRound);
on<GameOver>(_onGameOver);
}
void _onStartGame(StartGame event, Emitter<GameState> emit) {
_gameChannel = dataSource.subscribeToGame(
gameSessionId: gameSessionId,
onRoundReveal: (payload) => add(RoundRevealed(
roundNumber: payload['roundNumber'] as int,
player1Choice: payload['player1Choice'] as String,
player2Choice: payload['player2Choice'] as String,
isSync: payload['isSync'] as bool,
)),
onPlayerSubmitted: (payload) => add(PlayerSubmittedEvent(
playerId: payload['playerId'] as String,
)),
);
_startCountdown(emit);
}
void _startCountdown(Emitter<GameState> emit) {
int seconds = 3;
emit(CountdownState(secondsLeft: seconds));
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
seconds--;
if (seconds <= 0) {
timer.cancel();
add(NextRound());
} else {
emit(CountdownState(secondsLeft: seconds));
}
});
}
void _onNextRound(NextRound event, Emitter<GameState> emit) {
emit(ChoosingState(
roundNumber: _currentRound,
currentSyncScore: _syncScore,
options: getOptionsForRound(_currentRound),
));
}
Future<void> _onSubmitChoice(SubmitChoice event, Emitter<GameState> emit) async {
emit(WaitingForOpponentState(
roundNumber: _currentRound,
myChoice: event.choice,
));
await dataSource.submitChoice(
gameSessionId: gameSessionId,
choice: event.choice,
roundNumber: _currentRound,
);
}
void _onPlayerSubmitted(PlayerSubmittedEvent event, Emitter<GameState> emit) {}
void _onRoundRevealed(RoundRevealed event, Emitter<GameState> emit) {
if (event.isSync) _syncScore++;
_rounds.add({
'roundNumber': event.roundNumber,
'myChoice': currentUserId == event.player1Choice ? event.player1Choice : event.player2Choice,
'opponentChoice': currentUserId == event.player1Choice ? event.player2Choice : event.player1Choice,
'isSync': event.isSync,
});
emit(RevealState(
roundNumber: event.roundNumber,
player1Choice: event.player1Choice,
player2Choice: event.player2Choice,
isSync: event.isSync,
currentSyncScore: _syncScore,
));
Future.delayed(const Duration(seconds: 2), () {
if (_currentRound >= 20) {
add(GameOver());
} else {
_currentRound++;
add(NextRound());
}
});
}
void _onGameOver(GameOver event, Emitter<GameState> emit) {
emit(GameOverState(
finalSyncScore: _syncScore,
totalRounds: 20,
rounds: List.from(_rounds),
));
}
@override
Future<void> close() {
_countdownTimer?.cancel();
_gameChannel?.unsubscribe();
return super.close();
}
}
Build the game screen UI
The UI reacts to each state the GameBloc emits. A countdown shows big numbers. The choosing state presents buttons. The waiting state shows a spinner. The reveal state flashes sync or no-sync.
- Open lib/presentation/screens/game_screen.dart and replace its contents with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../core/injection_container.dart';
import '../blocs/game/game_bloc.dart';
import 'results_screen.dart';
class GameScreen extends StatelessWidget {
final String gameSessionId;
final String opponentUsername;
const GameScreen({
super.key,
required this.gameSessionId,
required this.opponentUsername,
});
@override
Widget build(BuildContext context) {
// Get the current user ID for identifying player choices
final userId = Supabase.instance.client.auth.currentUser!.id;
return BlocProvider(
create: (_) => GameBloc(
dataSource: sl<GameRemoteDataSource>(),
gameSessionId: gameSessionId,
currentUserId: userId,
)..add(StartGame()),
child: BlocConsumer<GameBloc, GameState>(
listener: (context, state) {
// Navigate to results when game ends
if (state is GameOverState) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => ResultsScreen(
finalSyncScore: state.finalSyncScore,
totalRounds: state.totalRounds,
rounds: state.rounds,
),
),
);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text('vs $opponentUsername'),
automaticallyImplyLeading: false,
),
body: _buildBody(context, state),
);
},
),
);
}
// Render the appropriate UI for each game state
Widget _buildBody(BuildContext context, GameState state) {
if (state is CountdownState) {
return Center(
child: Text('${state.secondsLeft}', style: const TextStyle(fontSize: 80, fontWeight: FontWeight.bold)),
);
}
if (state is ChoosingState) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Round ${state.roundNumber}/20', style: const TextStyle(fontSize: 20)),
Text('Syncs: ${state.currentSyncScore}', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 32),
const Text('Choose!', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: state.options.map((option) => ElevatedButton(
onPressed: () => context.read<GameBloc>().add(SubmitChoice(option)),
style: ElevatedButton.styleFrom(minimumSize: const Size(120, 60)),
child: Text(option),
)).toList(),
),
],
);
}
if (state is WaitingForOpponentState) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You chose: ${state.myChoice}', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 16),
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('Waiting for opponent...'),
],
),
);
}
if (state is RevealState) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(state.isSync ? '\u2728 SYNC!' : '\u274C No Sync', style: TextStyle(fontSize: 36, color: state.isSync ? Colors.green : Colors.red)),
const SizedBox(height: 24),
Text('Player 1: ${state.player1Choice}', style: const TextStyle(fontSize: 20)),
Text('Player 2: ${state.player2Choice}', style: const TextStyle(fontSize: 20)),
const SizedBox(height: 16),
Text('Syncs: ${state.currentSyncScore}', style: const TextStyle(fontSize: 18)),
],
),
);
}
return const Center(child: CircularProgressIndicator());
}
}
What does this code do?
- BlocProvider creates the GameBloc with the data source from the service locator and immediately fires StartGame.
- BlocConsumer handles both state-driven navigation (listener) and UI rebuilds (builder).
- The _buildBody method maps each state to its visual representation. Countdown shows a large number. Choosing shows option buttons. Waiting shows a spinner. Reveal shows both choices with color-coded sync status.
- When GameOverState is emitted, the listener navigates to the results screen with the final data.
- Save game_screen.dart.
- Now create a placeholder results screen. Create lib/presentation/screens/results_screen.dart with the following code:
import 'package:flutter/material.dart';
class ResultsScreen extends StatelessWidget {
final int finalSyncScore;
final int totalRounds;
final List<Map<String, dynamic>> rounds;
const ResultsScreen({
super.key,
required this.finalSyncScore,
required this.totalRounds,
required this.rounds,
});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text('Results: $finalSyncScore/$totalRounds syncs - Coming Soon'),
),
);
}
}
What does this code do?
This is a temporary placeholder that confirms navigation from the game screen works. You will build the full results screen with score breakdowns and streak highlights in the next step.
- Save results_screen.dart.
- Run your app on your physical device and emulator simultaneously. Both should be able to match in the lobby and enter the game screen.
- You should see a 3-2-1 countdown, then four choice buttons appear. After both players tap a choice, the reveal animation shows both choices with a SYNC or No Sync indicator.
Game screen shows a loading spinner that never stops?
This usually means the StartGame event was not fired. Confirm your BlocProvider uses ..add(StartGame()) after creating the GameBloc.
If the countdown works but choices never reveal, check that your validate-choice Edge Function is deployed and sending broadcast events. You can verify this in the Supabase Dashboard under Edge Functions > Logs.
Need help debugging? Help me troubleshoot why my GameBloc isn't receiving broadcast events from the Edge Function.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both screen files.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../core/injection_container.dart';
import '../blocs/game/game_bloc.dart';
import 'results_screen.dart';
class GameScreen extends StatelessWidget {
final String gameSessionId;
final String opponentUsername;
const GameScreen({
super.key,
required this.gameSessionId,
required this.opponentUsername,
});
@override
Widget build(BuildContext context) {
final userId = Supabase.instance.client.auth.currentUser!.id;
return BlocProvider(
create: (_) => GameBloc(
dataSource: sl<GameRemoteDataSource>(),
gameSessionId: gameSessionId,
currentUserId: userId,
)..add(StartGame()),
child: BlocConsumer<GameBloc, GameState>(
listener: (context, state) {
if (state is GameOverState) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => ResultsScreen(
finalSyncScore: state.finalSyncScore,
totalRounds: state.totalRounds,
rounds: state.rounds,
),
),
);
}
},
builder: (context, state) {
return Scaffold(
appBar: AppBar(
title: Text('vs $opponentUsername'),
automaticallyImplyLeading: false,
),
body: _buildBody(context, state),
);
},
),
);
}
Widget _buildBody(BuildContext context, GameState state) {
if (state is CountdownState) {
return Center(
child: Text('${state.secondsLeft}', style: const TextStyle(fontSize: 80, fontWeight: FontWeight.bold)),
);
}
if (state is ChoosingState) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Round ${state.roundNumber}/20', style: const TextStyle(fontSize: 20)),
Text('Syncs: ${state.currentSyncScore}', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 32),
const Text('Choose!', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: state.options.map((option) => ElevatedButton(
onPressed: () => context.read<GameBloc>().add(SubmitChoice(option)),
style: ElevatedButton.styleFrom(minimumSize: const Size(120, 60)),
child: Text(option),
)).toList(),
),
],
);
}
if (state is WaitingForOpponentState) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('You chose: ${state.myChoice}', style: const TextStyle(fontSize: 24)),
const SizedBox(height: 16),
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('Waiting for opponent...'),
],
),
);
}
if (state is RevealState) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(state.isSync ? '\u2728 SYNC!' : '\u274C No Sync', style: TextStyle(fontSize: 36, color: state.isSync ? Colors.green : Colors.red)),
const SizedBox(height: 24),
Text('Player 1: ${state.player1Choice}', style: const TextStyle(fontSize: 20)),
Text('Player 2: ${state.player2Choice}', style: const TextStyle(fontSize: 20)),
const SizedBox(height: 16),
Text('Syncs: ${state.currentSyncScore}', style: const TextStyle(fontSize: 18)),
],
),
);
}
return const Center(child: CircularProgressIndicator());
}
}
Your game loop is alive. Two players can match, countdown together, make blind choices, and discover their sync in real-time. Next up, you will build the results screen that shows their final sync score with a round-by-round breakdown.
Build Results, Stats, and Game History
Your game loop is complete. Players can match, play 20 rounds, and see the final GameOverState. But a score number alone does not make a satisfying ending.
In this step, you will build the post-game experience. A results screen that breaks down every round, a stats system that tracks lifetime performance, and a profile screen where players can see their sync history over time.
In this step, get ready to:
- Build the results screen with sync percentage, round breakdown, and streak highlights.
- Implement the StatsBloc and data source that queries game history.
- Create the profile screen displaying lifetime statistics and recent games.
Build the results screen
Right now, your ResultsScreen is a placeholder that just shows the score. You will replace it with a full breakdown showing the sync percentage, each round's choices, and the longest streak of consecutive syncs.
- Open lib/presentation/screens/results_screen.dart and replace its entire contents with the following code:
import 'package:flutter/material.dart';
import 'lobby_screen.dart';
import 'profile_screen.dart';
class ResultsScreen extends StatelessWidget {
final int finalSyncScore;
final int totalRounds;
final List<Map<String, dynamic>> rounds;
const ResultsScreen({
super.key,
required this.finalSyncScore,
required this.totalRounds,
required this.rounds,
});
// Calculate the longest consecutive sync streak
int _calculateBestStreak() {
int best = 0;
int current = 0;
for (final round in rounds) {
if (round['isSync'] == true) {
current++;
if (current > best) best = current;
} else {
current = 0;
}
}
return best;
}
@override
Widget build(BuildContext context) {
final syncPercent = totalRounds > 0 ? (finalSyncScore / totalRounds * 100).toStringAsFixed(0) : '0';
final bestStreak = _calculateBestStreak();
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
const Text('Game Over!', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
// Sync score and percentage headline
Text('$finalSyncScore/$totalRounds Syncs', style: const TextStyle(fontSize: 28)),
Text('$syncPercent% Sync Rate', style: const TextStyle(fontSize: 20, color: Colors.deepPurple)),
Text('Best Streak: $bestStreak', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 24),
const Text('Round Breakdown', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
// Round-by-round list
Expanded(
child: ListView.builder(
itemCount: rounds.length,
itemBuilder: (context, index) {
final round = rounds[index];
final isSync = round['isSync'] as bool? ?? false;
return ListTile(
leading: Icon(isSync ? Icons.check_circle : Icons.cancel,
color: isSync ? Colors.green : Colors.red),
title: Text('Round ${round['roundNumber']}'),
subtitle: Text('You: ${round['myChoice']} | Opponent: ${round['opponentChoice']}'),
);
},
),
),
// Navigation buttons
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LobbyScreen()),
(route) => false,
),
child: const Text('Play Again'),
),
OutlinedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ProfileScreen()),
),
child: const Text('View Stats'),
),
],
),
],
),
),
),
);
}
}
What does this code do?
The results screen has three sections working together to give the player a complete picture of their game.
- _calculateBestStreak() loops through the rounds and tracks the longest run of consecutive syncs. This is the streak highlight.
- The headline section shows the raw score (e.g., 15/20), the percentage, and the best streak.
- The ListView shows each round with a green checkmark or red X, plus both players' choices.
- The navigation row has a Play Again button that clears the nav stack back to the lobby, and a View Stats button that pushes to the profile screen you will build next.
- Save results_screen.dart.
You will see a compile error because ProfileScreen does not exist yet. That is expected and you will fix it by the end of this step.
Seeing unrelated errors?
Make sure the import for lobby_screen.dart matches your actual file path. If your LobbyScreen class lives in a different location, adjust the import accordingly.
Help me debug import errors in my results screen.
Create the stats data source and BLoC
The profile screen needs data to display. You will create a data source that queries your game_sessions and rounds tables, and a BLoC that calculates aggregate statistics from that raw data.
- Create a new file at lib/data/datasources/stats_remote_data_source.dart and add the following code:
import 'package:supabase_flutter/supabase_flutter.dart';
abstract class StatsRemoteDataSource {
Future<List<Map<String, dynamic>>> getGameHistory(String userId);
Future<List<Map<String, dynamic>>> getRoundsBySession(String sessionId);
}
class StatsRemoteDataSourceImpl implements StatsRemoteDataSource {
final SupabaseClient client;
StatsRemoteDataSourceImpl(this.client);
@override
Future<List<Map<String, dynamic>>> getGameHistory(String userId) async {
// Query completed games where this user was a participant
final data = await client
.from('game_sessions')
.select('*')
.or('player1_id.eq.$userId,player2_id.eq.$userId')
.eq('status', 'completed')
.order('completed_at', ascending: false)
.limit(20);
return List<Map<String, dynamic>>.from(data);
}
@override
Future<List<Map<String, dynamic>>> getRoundsBySession(String sessionId) async {
// Fetch all rounds for a specific game session
final data = await client
.from('rounds')
.select('*')
.eq('game_session_id', sessionId)
.order('round_number');
return List<Map<String, dynamic>>.from(data);
}
}
What does this data source do?
- getGameHistory() uses the .or() filter to find games where the user was either player1 or player2. It only returns completed games, ordered by most recent first.
- getRoundsBySession() fetches all rounds for a single game session, ordered by round number. The StatsBloc will use this to calculate streaks.
- Save stats_remote_data_source.dart.
Now create the BLoC that consumes this data source and calculates aggregate stats.
- Create a new folder at lib/presentation/blocs/stats/.
- Create lib/presentation/blocs/stats/stats_event.dart with the following code:
part of 'stats_bloc.dart';
abstract class StatsEvent extends Equatable {
@override
List<Object?> get props => [];
}
class LoadStats extends StatsEvent {
final String userId;
LoadStats(this.userId);
@override
List<Object?> get props => [userId];
}
What does this event do?
LoadStats is the only event for this BLoC. It carries the user ID so the BLoC knows whose game history to fetch. The profile screen dispatches this event when it first loads.
- Save stats_event.dart.
- Create lib/presentation/blocs/stats/stats_state.dart with the following code:
part of 'stats_bloc.dart';
abstract class StatsState extends Equatable {
@override
List<Object?> get props => [];
}
class StatsInitial extends StatsState {}
class StatsLoading extends StatsState {}
class StatsLoaded extends StatsState {
final int totalGames;
final double averageSyncRate;
final int bestStreak;
final List<Map<String, dynamic>> recentGames;
StatsLoaded({
required this.totalGames,
required this.averageSyncRate,
required this.bestStreak,
required this.recentGames,
});
@override
List<Object?> get props => [totalGames, averageSyncRate, bestStreak, recentGames];
}
What do these states represent?
- StatsInitial is the default state before anything loads (also used as a fallback on error).
- StatsLoading signals the UI to show a spinner while data is being fetched.
- StatsLoaded holds the computed stats: total games played, average sync rate as a decimal (0.0 to 1.0), the best streak across all games, and the raw list of recent games for display.
- Save stats_state.dart.
- Create lib/presentation/blocs/stats/stats_bloc.dart with the following code:
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../../data/datasources/stats_remote_data_source.dart';
part 'stats_event.dart';
part 'stats_state.dart';
class StatsBloc extends Bloc<StatsEvent, StatsState> {
final StatsRemoteDataSource dataSource;
StatsBloc(this.dataSource) : super(StatsInitial()) {
on<LoadStats>(_onLoadStats);
}
Future<void> _onLoadStats(LoadStats event, Emitter<StatsState> emit) async {
emit(StatsLoading());
try {
final games = await dataSource.getGameHistory(event.userId);
if (games.isEmpty) {
emit(StatsLoaded(
totalGames: 0,
averageSyncRate: 0.0,
bestStreak: 0,
recentGames: [],
));
return;
}
final totalGames = games.length;
double totalSyncRate = 0;
int bestStreak = 0;
// Loop through each game to calculate aggregate stats
for (final game in games) {
final syncScore = (game['sync_score'] as int?) ?? 0;
final totalRounds = (game['total_rounds'] as int?) ?? 20;
totalSyncRate += syncScore / totalRounds;
// Fetch rounds for streak calculation
final rounds = await dataSource.getRoundsBySession(game['id'] as String);
int currentStreak = 0;
for (final round in rounds) {
if (round['is_sync'] == true) {
currentStreak++;
if (currentStreak > bestStreak) bestStreak = currentStreak;
} else {
currentStreak = 0;
}
}
}
emit(StatsLoaded(
totalGames: totalGames,
averageSyncRate: totalSyncRate / totalGames,
bestStreak: bestStreak,
recentGames: games,
));
} catch (e) {
emit(StatsInitial());
}
}
}
How does the stats calculation work?
The _onLoadStats handler fetches all completed games, then iterates through each one. For each game it calculates the sync rate (sync_score divided by total_rounds) and adds it to a running total.
For the best streak, it fetches the individual rounds of each game and tracks the longest consecutive run of is_sync == true across all games the player has ever played. The average sync rate is the sum of all per-game rates divided by the number of games.
- Save stats_bloc.dart.
Seeing part file errors?
The part and part of directives must reference the exact file names. Make sure stats_bloc.dart has part 'stats_event.dart' and part 'stats_state.dart', and both part files have part of 'stats_bloc.dart' at the top.
Help me fix part file errors in my Flutter BLoC.
Build the profile screen and register dependencies
The profile screen is where players see their lifetime stats. It creates a StatsBloc, dispatches LoadStats with the current user ID, and displays the results.
- Create lib/presentation/screens/profile_screen.dart with the following code:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../core/injection_container.dart';
import '../blocs/stats/stats_bloc.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final userId = Supabase.instance.client.auth.currentUser!.id;
return BlocProvider(
// Create StatsBloc and immediately load stats for this user
create: (_) => sl<StatsBloc>()..add(LoadStats(userId)),
child: Scaffold(
appBar: AppBar(title: const Text('My Stats')),
body: BlocBuilder<StatsBloc, StatsState>(
builder: (context, state) {
if (state is StatsLoading) return const Center(child: CircularProgressIndicator());
if (state is StatsLoaded) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Lifetime statistics
Text('Total Games: ${state.totalGames}', style: const TextStyle(fontSize: 18)),
Text('Avg Sync Rate: ${(state.averageSyncRate * 100).toStringAsFixed(1)}%', style: const TextStyle(fontSize: 18)),
Text('Best Streak: ${state.bestStreak}', style: const TextStyle(fontSize: 18)),
const SizedBox(height: 24),
const Text('Recent Games', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
// Recent game history list
Expanded(
child: ListView.builder(
itemCount: state.recentGames.length,
itemBuilder: (context, index) {
final game = state.recentGames[index];
final syncScore = game['sync_score'] as int? ?? 0;
final totalRounds = game['total_rounds'] as int? ?? 20;
return ListTile(
title: Text('$syncScore/$totalRounds syncs'),
subtitle: Text(game['completed_at']?.toString().substring(0, 10) ?? ''),
);
},
),
),
],
),
);
}
return const Center(child: Text('No stats yet'));
},
),
),
);
}
}
What does this screen show?
- The top section displays three lifetime stats: total games, average sync rate (as a percentage), and best streak.
- Below that, a scrollable list shows recent games with their sync score and the date they were played.
- The BlocProvider creates a fresh StatsBloc each time the screen opens, ensuring stats are always up to date.
- Save profile_screen.dart.
The final piece is registering the new data source and BLoC in your dependency injection container so the app can create them.
- Open lib/core/injection_container.dart and replace its entire contents with the following code:
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../presentation/blocs/auth/auth_bloc.dart';
import '../presentation/blocs/matchmaking/matchmaking_bloc.dart';
import '../presentation/blocs/stats/stats_bloc.dart';
import '../data/datasources/auth_remote_data_source.dart';
import '../data/datasources/matchmaking_remote_data_source.dart';
import '../data/datasources/game_remote_data_source.dart';
import '../data/datasources/stats_remote_data_source.dart';
final sl = GetIt.instance;
void setupInjection() {
sl.registerLazySingleton(() => Supabase.instance.client);
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<MatchmakingRemoteDataSource>(
() => MatchmakingRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<GameRemoteDataSource>(
() => GameRemoteDataSourceImpl(sl()),
);
// Register stats data source and BLoC
sl.registerLazySingleton<StatsRemoteDataSource>(
() => StatsRemoteDataSourceImpl(sl()),
);
sl.registerFactory(() => AuthBloc(sl()));
sl.registerFactory(() => MatchmakingBloc(sl()));
sl.registerFactory(() => StatsBloc(sl()));
}
What changed in the injection container?
- Two new imports were added: stats_bloc.dart and stats_remote_data_source.dart.
- StatsRemoteDataSource is registered as a lazy singleton because one instance is reused across the app.
- StatsBloc is registered as a factory because each screen that uses it should get a fresh instance.
- Save injection_container.dart.
- Run your app on your device or emulator. Play a full game (or use two instances) and confirm the results screen shows the sync score percentage, round breakdown, and streak count.
- Tap View Stats on the results screen and verify the profile screen loads showing your game in the recent history.
Profile screen shows 'No stats yet'?
The StatsBloc only fetches games with status = 'completed'. If you quit a game early or the Edge Function did not mark the session as completed, no games will appear. Check the game_sessions table in your Supabase dashboard to confirm the status column shows completed for your test game.
Help me debug why my stats screen is empty.
✔️ Awesome, I've got everything!
Great. Double check you have saved all five files before moving on.
ⓧ I'd like to double check the full code
Here is each file as it should look at the end of this step.
import 'package:flutter/material.dart';
import 'lobby_screen.dart';
import 'profile_screen.dart';
class ResultsScreen extends StatelessWidget {
final int finalSyncScore;
final int totalRounds;
final List<Map<String, dynamic>> rounds;
const ResultsScreen({
super.key,
required this.finalSyncScore,
required this.totalRounds,
required this.rounds,
});
int _calculateBestStreak() {
int best = 0;
int current = 0;
for (final round in rounds) {
if (round['isSync'] == true) {
current++;
if (current > best) best = current;
} else {
current = 0;
}
}
return best;
}
@override
Widget build(BuildContext context) {
final syncPercent = totalRounds > 0 ? (finalSyncScore / totalRounds * 100).toStringAsFixed(0) : '0';
final bestStreak = _calculateBestStreak();
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
children: [
const Text('Game Over!', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Text('$finalSyncScore/$totalRounds Syncs', style: const TextStyle(fontSize: 28)),
Text('$syncPercent% Sync Rate', style: const TextStyle(fontSize: 20, color: Colors.deepPurple)),
Text('Best Streak: $bestStreak', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 24),
const Text('Round Breakdown', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Expanded(
child: ListView.builder(
itemCount: rounds.length,
itemBuilder: (context, index) {
final round = rounds[index];
final isSync = round['isSync'] as bool? ?? false;
return ListTile(
leading: Icon(isSync ? Icons.check_circle : Icons.cancel,
color: isSync ? Colors.green : Colors.red),
title: Text('Round ${round['roundNumber']}'),
subtitle: Text('You: ${round['myChoice']} | Opponent: ${round['opponentChoice']}'),
);
},
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const LobbyScreen()),
(route) => false,
),
child: const Text('Play Again'),
),
OutlinedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ProfileScreen()),
),
child: const Text('View Stats'),
),
],
),
],
),
),
),
);
}
}
import 'package:supabase_flutter/supabase_flutter.dart';
abstract class StatsRemoteDataSource {
Future<List<Map<String, dynamic>>> getGameHistory(String userId);
Future<List<Map<String, dynamic>>> getRoundsBySession(String sessionId);
}
class StatsRemoteDataSourceImpl implements StatsRemoteDataSource {
final SupabaseClient client;
StatsRemoteDataSourceImpl(this.client);
@override
Future<List<Map<String, dynamic>>> getGameHistory(String userId) async {
final data = await client
.from('game_sessions')
.select('*')
.or('player1_id.eq.$userId,player2_id.eq.$userId')
.eq('status', 'completed')
.order('completed_at', ascending: false)
.limit(20);
return List<Map<String, dynamic>>.from(data);
}
@override
Future<List<Map<String, dynamic>>> getRoundsBySession(String sessionId) async {
final data = await client
.from('rounds')
.select('*')
.eq('game_session_id', sessionId)
.order('round_number');
return List<Map<String, dynamic>>.from(data);
}
}
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
import '../../../data/datasources/stats_remote_data_source.dart';
part 'stats_event.dart';
part 'stats_state.dart';
class StatsBloc extends Bloc<StatsEvent, StatsState> {
final StatsRemoteDataSource dataSource;
StatsBloc(this.dataSource) : super(StatsInitial()) {
on<LoadStats>(_onLoadStats);
}
Future<void> _onLoadStats(LoadStats event, Emitter<StatsState> emit) async {
emit(StatsLoading());
try {
final games = await dataSource.getGameHistory(event.userId);
if (games.isEmpty) {
emit(StatsLoaded(
totalGames: 0,
averageSyncRate: 0.0,
bestStreak: 0,
recentGames: [],
));
return;
}
final totalGames = games.length;
double totalSyncRate = 0;
int bestStreak = 0;
for (final game in games) {
final syncScore = (game['sync_score'] as int?) ?? 0;
final totalRounds = (game['total_rounds'] as int?) ?? 20;
totalSyncRate += syncScore / totalRounds;
final rounds = await dataSource.getRoundsBySession(game['id'] as String);
int currentStreak = 0;
for (final round in rounds) {
if (round['is_sync'] == true) {
currentStreak++;
if (currentStreak > bestStreak) bestStreak = currentStreak;
} else {
currentStreak = 0;
}
}
}
emit(StatsLoaded(
totalGames: totalGames,
averageSyncRate: totalSyncRate / totalGames,
bestStreak: bestStreak,
recentGames: games,
));
} catch (e) {
emit(StatsInitial());
}
}
}
part of 'stats_bloc.dart';
abstract class StatsEvent extends Equatable {
@override
List<Object?> get props => [];
}
class LoadStats extends StatsEvent {
final String userId;
LoadStats(this.userId);
@override
List<Object?> get props => [userId];
}
part of 'stats_bloc.dart';
abstract class StatsState extends Equatable {
@override
List<Object?> get props => [];
}
class StatsInitial extends StatsState {}
class StatsLoading extends StatsState {}
class StatsLoaded extends StatsState {
final int totalGames;
final double averageSyncRate;
final int bestStreak;
final List<Map<String, dynamic>> recentGames;
StatsLoaded({
required this.totalGames,
required this.averageSyncRate,
required this.bestStreak,
required this.recentGames,
});
@override
List<Object?> get props => [totalGames, averageSyncRate, bestStreak, recentGames];
}
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../core/injection_container.dart';
import '../blocs/stats/stats_bloc.dart';
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key});
@override
Widget build(BuildContext context) {
final userId = Supabase.instance.client.auth.currentUser!.id;
return BlocProvider(
create: (_) => sl<StatsBloc>()..add(LoadStats(userId)),
child: Scaffold(
appBar: AppBar(title: const Text('My Stats')),
body: BlocBuilder<StatsBloc, StatsState>(
builder: (context, state) {
if (state is StatsLoading) return const Center(child: CircularProgressIndicator());
if (state is StatsLoaded) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Total Games: ${state.totalGames}', style: const TextStyle(fontSize: 18)),
Text('Avg Sync Rate: ${(state.averageSyncRate * 100).toStringAsFixed(1)}%', style: const TextStyle(fontSize: 18)),
Text('Best Streak: ${state.bestStreak}', style: const TextStyle(fontSize: 18)),
const SizedBox(height: 24),
const Text('Recent Games', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Expanded(
child: ListView.builder(
itemCount: state.recentGames.length,
itemBuilder: (context, index) {
final game = state.recentGames[index];
final syncScore = game['sync_score'] as int? ?? 0;
final totalRounds = game['total_rounds'] as int? ?? 20;
return ListTile(
title: Text('$syncScore/$totalRounds syncs'),
subtitle: Text(game['completed_at']?.toString().substring(0, 10) ?? ''),
);
},
),
),
],
),
);
}
return const Center(child: Text('No stats yet'));
},
),
),
);
}
}
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../presentation/blocs/auth/auth_bloc.dart';
import '../presentation/blocs/matchmaking/matchmaking_bloc.dart';
import '../presentation/blocs/stats/stats_bloc.dart';
import '../data/datasources/auth_remote_data_source.dart';
import '../data/datasources/matchmaking_remote_data_source.dart';
import '../data/datasources/game_remote_data_source.dart';
import '../data/datasources/stats_remote_data_source.dart';
final sl = GetIt.instance;
void setupInjection() {
sl.registerLazySingleton(() => Supabase.instance.client);
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<MatchmakingRemoteDataSource>(
() => MatchmakingRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<GameRemoteDataSource>(
() => GameRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<StatsRemoteDataSource>(
() => StatsRemoteDataSourceImpl(sl()),
);
sl.registerFactory(() => AuthBloc(sl()));
sl.registerFactory(() => MatchmakingBloc(sl()));
sl.registerFactory(() => StatsBloc(sl()));
}
Your game now has a complete post-game loop. Players finish a match, see their detailed results, and can track their performance over time. Next up, you will tackle the Secret Mission and add a rematch system with rivalry tracking.
Secret mission
Add a Rematch Feature and Rivalry Tracker
You just built a one-off game experience. But the best multiplayer games create persistent social connections. What if players could challenge the same opponent again and track their sync history over time?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Supabase free tier, so there are no ongoing costs. However, Supabase pauses inactive free-tier projects after 1 week.
Cost warning
While this project costs $0 to run, your Supabase project will automatically pause after 1 week of inactivity on the free tier. You can unpause it from the Supabase Dashboard at any time, but you will need to do so if you return after a break.
Resources you used:
- Supabase cloud project (database tables, RLS policies, Realtime publication, Auth).
- Supabase Edge Function (validate-choice).
- Flutter project (local files in mirror_match folder on your Desktop).
- Docker Desktop (local installation).
- Supabase CLI (installed via Scoop on Windows).
Keep everything running
No action needed. Choose this if you are still actively building or want to play Mirror Match with friends.
- Your Supabase project stays active as long as you interact with it at least once a week.
- Your local Flutter project files remain on your Desktop.
- Docker Desktop and Supabase CLI stay installed and ready for future projects.
Pause - I'll come back to this later
Free up local resources while keeping your cloud project and code intact.
- Quit Docker Desktop to free up RAM. Your Edge Function testing environment stops, but your deployed function remains live on Supabase.
- Your Supabase cloud project will auto-pause after 1 week of inactivity. You can unpause it from the Dashboard when you return.
- Keep your local Flutter project folder. You can pick up exactly where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- In the Supabase Dashboard, go to your project's Settings page. Scroll to Delete Project and confirm. This removes your database, Edge Function, Auth users, and all stored data permanently.
- Delete the mirror_match folder from your Desktop.
- Optionally uninstall Docker Desktop if you do not need it for other projects.
- Optionally uninstall the Supabase CLI by running scoop uninstall supabase in your terminal.
That's a Wrap!
That's a Wrap!
Nice work! You just built Mirror Match, a real-time multiplayer synchronicity game that most Flutter developers would never attempt.
Here's what you learned:
- Built a complete Flutter app with clean architecture and BLoC managing a complex real-time game state machine with countdown, choice, reveal, and scoring phases.
- Deployed a Supabase Edge Function in TypeScript that handles server-side choice validation and prevents cheating through timestamp verification.
- Implemented real-time matchmaking with Supabase Presence and game synchronization with Realtime Broadcast, coordinating two players across separate devices.
- Added a rivalry tracker that uses Presence-based online detection and persistent head-to-head sync history to turn one-off games into social connections.
Ready to quiz yourself?