Introduction to the Ludo Flutter Tutorial
This Ludo Flutter tutorial walks you through building a production-ready, cross-platform Ludo board game from scratch using Flutter and Dart. The tutorial covers every critical layer of a modern Flutter application: Clean Architecture with clear separation of concerns, the BLoC pattern for predictable game state management, CustomPainter for pixel-precise board rendering, web_socket_channel for real-time multiplayer synchronization, Firebase Cloud Messaging for push notifications, and App Links for seamless deep linking into specific game rooms.
Flutter's hot reload accelerates development cycles dramatically — you can modify game logic, update board rendering, or adjust animations and see changes reflected instantly on a connected device or simulator. The single codebase compiles to native ARM code for both iOS and Android, delivering near-native performance without the overhead of JavaScript-based cross-platform frameworks. By the end of this guide, you will have a fully functional Ludo game that supports local play, AI opponents, and real-time multiplayer matches against remote players.
This tutorial assumes familiarity with Dart and basic Flutter widgets. If you are new to Flutter, start with the official Flutter codelabs before proceeding. For backend services that handle matchmaking, room persistence, and leaderboards, see the Ludo Realtime API which provides a production-grade Socket.IO backend.
Prerequisites
Before starting, verify your development environment is properly configured. Run flutter doctor in your terminal and address any issues before proceeding. The required tools and accounts are:
- Flutter SDK 3.16 or later — Install via
brew install flutteron macOS or download fromflutter.dev - Dart 3.2 or later — Bundled with Flutter SDK
- VS Code with the Flutter and Dart extensions, or Android Studio with the Flutter plugin
- Xcode 15+ (macOS only) — Required for iOS builds, App Store submission, and simulator testing
- Android SDK (API level 21 minimum) — For Android builds and Play Store deployment
- Firebase project — Create one at
console.firebase.google.comfor push notifications - Apple Developer account — Required for App Links on iOS and Ad Hoc / App Store distribution
- Basic understanding of Dart syntax, widget lifecycle, and state management concepts
For building on Android, ensure the Android SDK license is accepted (flutter doctor --android-licenses). For iOS, you need at least one valid code signing identity. Both platforms can build for simulators without signing, but you need a Developer account for physical device testing and distribution.
Step 1 — Flutter Project Setup
Create a new Flutter project and configure all dependencies. The flutter create scaffolding tool generates a complete runnable app structure with platform-specific folders for iOS and Android.
# Create a new Flutter project with the correct organization identifier
flutter create --org com.ludoking --project-name ludo_game --platforms=ios,android ludo_game
cd ludo_game
# Add BLoC and state management dependencies
flutter pub add flutter_bloc equatable
# Add WebSocket support
flutter pub add web_socket_channel
# Add Firebase dependencies for push notifications
flutter pub add firebase_core firebase_messaging
# Add App Links (deep linking) support
flutter pub add uni_links
# Add Google Fonts for typography
flutter pub add google_fonts
# Install all dependencies
flutter pub get
# Verify the setup
flutter doctor
The flutter_bloc package provides the BLoC (Business Logic Component) implementation with first-class Dart support. equatable enables value-based equality for state objects, allowing Flutter's rendering pipeline to detect state changes efficiently. web_socket_channel is Flutter's native WebSocket client — it is lighter than socket_io_client and works perfectly when your backend uses raw WebSocket connections, as supported by the Ludo Realtime API. The uni_links package handles both universal links on iOS and App Links on Android, enabling players to join game rooms via URL.
For Firebase, you also need to download the google-services.json from your Firebase project console and place it in android/app/, and download GoogleService-Info.plist for iOS placement in ios/Runner/. Without these configuration files, Firebase initialization will fail silently and push notifications will not work.
Step 2 — Clean Architecture Overview
Clean Architecture separates an application into concentric layers, each with a specific responsibility. The outermost layer (presentation) depends on the inner layers (domain, data), but never the reverse. This inversion of control makes the codebase testable, maintainable, and adaptable to future changes. For a Ludo game, this architecture ensures that game rules are completely independent of Flutter widgets and WebSocket communication — you can change the UI framework or the networking layer without touching the core game logic.
The domain layer contains entities (Player, Token, BoardCell), use cases (RollDice, MoveToken, CheckWinner), and repository interfaces. The data layer implements those interfaces using concrete data sources (in-memory for local games, WebSocket for multiplayer). The presentation layer contains Flutter widgets, BLoCs, and screens that consume the data layer through the domain layer's abstractions.
lib/
├── main.dart # App entry, Firebase init, runApp
├── app.dart # MaterialApp with BLoC providers
├── core/
│ ├── constants/
│ │ └── board_constants.dart # Board dimensions, colors, cell indices
│ ├── theme/
│ │ └── app_theme.dart # Material theme, colors, typography
│ └── utils/
│ └── id_generator.dart
├── domain/
│ ├── entities/
│ │ ├── player.dart
│ │ ├── token.dart
│ │ ├── board_cell.dart
│ │ └── game_result.dart
│ ├── repositories/
│ │ └── game_repository.dart # Abstract repository interface
│ └── usecases/
│ ├── roll_dice_usecase.dart
│ ├── move_token_usecase.dart
│ └── check_winner_usecase.dart
├── data/
│ ├── models/
│ │ ├── player_model.dart
│ │ ├── token_model.dart
│ │ └── game_state_model.dart
│ ├── repositories/
│ │ └── game_repository_impl.dart # Concrete implementation
│ └── datasources/
│ ├── local_game_datasource.dart
│ └── websocket_datasource.dart
├── presentation/
│ ├── bloc/
│ │ ├── game_bloc.dart
│ │ ├── game_event.dart
│ │ └── game_state.dart
│ ├── screens/
│ │ ├── home_screen.dart
│ │ ├── game_screen.dart
│ │ └── multiplayer_lobby_screen.dart
│ └── widgets/
│ ├── ludo_board.dart
│ ├── board_painter.dart
│ ├── dice_widget.dart
│ ├── token_widget.dart
│ └── player_panel.dart
└── services/
├── websocket_service.dart # web_socket_channel wrapper
├── notification_service.dart # Firebase Cloud Messaging
└── deep_link_service.dart # uni_links handler
The domain layer in lib/domain/ contains pure Dart classes with no Flutter dependencies — these are trivial to unit test using standard Dart test runners. The data layer in lib/data/ implements the repository interfaces defined in the domain layer. The presentation layer in lib/presentation/ contains Flutter-specific code. The services layer in lib/services/ bridges external systems (WebSocket servers, Firebase, deep link handlers) into the application's dependency graph.
Step 3 — CustomPainter Board Rendering
The Ludo board is a 15×15 grid with colored home bases, a central finish area, a cross-shaped path, and special cells (star cells and safe cells). Rendering this geometry efficiently requires Flutter's CustomPainter, which provides direct access to the Canvas API. Using stacked Container widgets for each cell would create hundreds of widget nodes and cause severe performance degradation on lower-end devices — CustomPainter draws everything on a single canvas layer, keeping frame rates smooth even on budget Android phones.
The LudoBoardPainter class below implements a complete Ludo board renderer with support for colored home bases, path cells, star markers, token rendering with drop shadows, selection highlighting, and valid-move cell highlighting:
import 'package:flutter/material.dart';
import '../../domain/entities/token.dart';
import '../../domain/entities/board_cell.dart';
import '../../core/constants/board_constants.dart';
/// CustomPainter that renders the complete Ludo board on a Flutter Canvas.
/// Uses a 15×15 grid coordinate system where each cell occupies
/// size.width / 15 pixels, ensuring proportional scaling on any screen.
class LudoBoardPainter extends CustomPainter {
final List<Token> tokens;
final String? selectedTokenId;
final List<int> validMoveIndices;
final int currentPlayerIndex;
LudoBoardPainter({
required this.tokens,
this.selectedTokenId,
this.validMoveIndices = const [],
required this.currentPlayerIndex,
});
@override
void paint(Canvas canvas, Size size) {
final cellSize = size.width / 15;
// --- 1. Draw board background ---
final bgPaint = Paint()..color = const Color(0xFFF5DEB3);
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint);
// --- 2. Draw colored home bases (corners) ---
_drawHomeBase(canvas, BoardConstants.redHome, const Color(0xFFE53935), cellSize);
_drawHomeBase(canvas, BoardConstants.greenHome, const Color(0xFF43A047), cellSize);
_drawHomeBase(canvas, BoardConstants.yellowHome, const Color(0xFFFDD835), cellSize);
_drawHomeBase(canvas, BoardConstants.blueHome, const Color(0xFF1E88E5), cellSize);
// --- 3. Draw the shared path cells ---
final pathCells = BoardConstants.sharedPath;
for (final cell in pathCells) {
final isHighlighted = validMoveIndices.contains(cell.index);
final isStar = BoardConstants.starCells.contains(cell.index);
final isSafe = BoardConstants.safeCells.contains(cell.index);
final cellPaint = Paint()
..color = isHighlighted
? const Color(0xFF4CAF50).withOpacity(0.5)
: const Color(0xFFE8D5B0)
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(cell.x * cellSize, cell.y * cellSize, cellSize, cellSize),
cellPaint,
);
// Draw cell border
final borderPaint = Paint()
..color = Colors.black26
..style = PaintingStyle.stroke
..strokeWidth = 0.5;
canvas.drawRect(
Rect.fromLTWH(cell.x * cellSize, cell.y * cellSize, cellSize, cellSize),
borderPaint,
);
// Draw star marker on star cells
if (isStar) {
_drawStarMarker(canvas, cell.x * cellSize + cellSize / 2,
cell.y * cellSize + cellSize / 2, cellSize * 0.3);
}
}
// --- 4. Draw home columns (colored paths leading to center) ---
for (final entry in BoardConstants.homeColumns.entries) {
final color = _playerColor(entry.key);
for (final cell in entry.value) {
final homePaint = Paint()
..color = color.withOpacity(0.7)
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(cell.x * cellSize, cell.y * cellSize, cellSize, cellSize),
homePaint,
);
}
}
// --- 5. Draw center finish area ---
final centerPaint = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawRect(
Rect.fromLTWH(6 * cellSize, 6 * cellSize, 3 * cellSize, 3 * cellSize),
centerPaint,
);
// --- 6. Draw tokens on the board ---
final tokenRadius = cellSize * 0.32;
for (final token in tokens) {
final cell = BoardConstants.getCellAt(token.cellIndex);
if (cell == null) continue; // Token is in home base, rendered separately
final color = _playerColor(token.playerIndex);
// Drop shadow
final shadowPaint = Paint()
..color = Colors.black38
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
canvas.drawCircle(
Offset(cell.x * cellSize + cellSize / 2 + 2,
cell.y * cellSize + cellSize / 2 + 2),
tokenRadius,
shadowPaint,
);
// Token body
final tokenPaint = Paint()..color = color..style = PaintingStyle.fill;
canvas.drawCircle(
Offset(cell.x * cellSize + cellSize / 2,
cell.y * cellSize + cellSize / 2),
tokenRadius,
tokenPaint,
);
// Token inner highlight
final highlightPaint = Paint()
..color = Colors.white38
..style = PaintingStyle.fill;
canvas.drawCircle(
Offset(cell.x * cellSize + cellSize / 2 - tokenRadius * 0.25,
cell.y * cellSize + cellSize / 2 - tokenRadius * 0.25),
tokenRadius * 0.3,
highlightPaint,
);
// White border if selected
if (token.id == selectedTokenId) {
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 3;
canvas.drawCircle(
Offset(cell.x * cellSize + cellSize / 2,
cell.y * cellSize + cellSize / 2),
tokenRadius,
borderPaint,
);
// Pulsing glow effect via stroke
final glowPaint = Paint()
..color = Colors.white.withOpacity(0.3)
..style = PaintingStyle.stroke
..strokeWidth = 6;
canvas.drawCircle(
Offset(cell.x * cellSize + cellSize / 2,
cell.y * cellSize + cellSize / 2),
tokenRadius + 3,
glowPaint,
);
}
}
}
void _drawHomeBase(Canvas canvas, List<BoardCell> cells, Color color, double cellSize) {
final paint = Paint()..color = color.withOpacity(0.6)..style = PaintingStyle.fill;
for (final cell in cells) {
canvas.drawRect(
Rect.fromLTWH(cell.x * cellSize, cell.y * cellSize, cellSize, cellSize),
paint,
);
}
// Draw home circle in the center of the home base
if (cells.isNotEmpty) {
final centerX = (cells[0].x + 1) * cellSize;
final centerY = (cells[0].y + 1) * cellSize;
final homePaint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(centerX, centerY), cellSize * 0.8, homePaint);
}
}
void _drawStarMarker(Canvas canvas, double cx, double cy, double radius) {
final paint = Paint()..color = Colors.black87..style = PaintingStyle.fill;
final path = Path();
const double outerRadius = radius;
const double innerRadius = radius * 0.4;
for (int i = 0; i < 10; i++) {
final r = i.isEven ? outerRadius : innerRadius;
final angle = (i * 36 - 90) * 3.14159265359 / 180;
if (i == 0) {
path.moveTo(cx + r * _cos(angle), cy + r * _sin(angle));
} else {
path.lineTo(cx + r * _cos(angle), cy + r * _sin(angle));
}
}
path.close();
canvas.drawPath(path, paint);
}
double _cos(double angle) => _cosTable(angle.toInt()) ?? _approximateCos(angle);
double _sin(double angle) => _cos(angle - 3.14159265359 / 2);
double _approximateCos(double angle) {
// Simple Taylor series approximation for cosine
angle = angle % (2 * 3.14159265359);
return 1 - (angle * angle) / 2 + (angle * angle * angle * angle) / 24;
}
double? _cosTable(int deg) => null; // Placeholder; actual implementation caches values
Color _playerColor(int playerIndex) {
const colors = [
Color(0xFFE53935), // Red
Color(0xFF43A047), // Green
Color(0xFFFDD835), // Yellow
Color(0xFF1E88E5), // Blue
];
return colors[playerIndex % 4];
}
@override
bool shouldRepaint(covariant LudoBoardPainter oldDelegate) {
return oldDelegate.tokens != tokens ||
oldDelegate.selectedTokenId != selectedTokenId ||
oldDelegate.validMoveIndices != validMoveIndices ||
oldDelegate.currentPlayerIndex != currentPlayerIndex;
}
}
The shouldRepaint method is critical for performance — it returns true only when any input parameter actually changed, preventing unnecessary redraws. The board uses a 15×15 grid coordinate system where cell coordinates are stored in BoardConstants as a list of BoardCell objects each with an x, y, and index. The LayoutBuilder in the parent widget supplies the available size, and the painter divides it by 15 to compute cellSize, ensuring the board scales proportionally on any screen density.
For the complete board coordinate data including home bases, paths, and star cell indices, refer to our Ludo Android API board data reference.
Step 4 — Full BLoC Implementation for Game State
The BLoC pattern converts a stream of incoming events (dice rolls, token selections, WebSocket messages) into a stream of outgoing states (current player, token positions, game phase). Each event handler is a pure function that takes the current state and produces a new state — no side effects, no mutable global state, and complete testability. For a Ludo game, this means every game rule (token movement, captures, safe zones, victory conditions) lives in the BLoC's event handlers, completely decoupled from the UI widgets that display the state.
The GameBloc below handles the complete game lifecycle: initializing players and tokens, rolling dice, selecting and moving tokens, detecting captures, checking for winners, and handling multiplayer sync events from the WebSocket layer.
import 'dart:async';
import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/entities/player.dart';
import '../../domain/entities/token.dart';
import '../../core/constants/board_constants.dart';
// =============================================================================
// GAME EVENTS
// =============================================================================
abstract class GameEvent extends Equatable {
const GameEvent();
@override
List<Object?> get props => [];
}
class InitializeGame extends GameEvent {
final List<Player> players;
final bool isMultiplayer;
const InitializeGame(this.players, {this.isMultiplayer = false});
@override
List<Object?> get props => [players, isMultiplayer];
}
class RollDiceEvent extends GameEvent {
const RollDiceEvent();
}
class SelectTokenEvent extends GameEvent {
final String tokenId;
const SelectTokenEvent(this.tokenId);
@override
List<Object?> get props => [tokenId];
}
class MoveTokenEvent extends GameEvent {
final String tokenId;
final int targetCellIndex;
const MoveTokenEvent(this.tokenId, this.targetCellIndex);
@override
List<Object?> get props => [tokenId, targetCellIndex];
}
class TokenDroppedEvent extends GameEvent {
final int playerIndex;
const TokenDroppedEvent(this.playerIndex);
}
class RemoteMoveEvent extends GameEvent {
final String playerId;
final String tokenId;
final int targetCellIndex;
final int newDiceValue;
const RemoteMoveEvent({
required this.playerId,
required this.tokenId,
required this.targetCellIndex,
required this.newDiceValue,
});
@override
List<Object?> get props => [playerId, tokenId, targetCellIndex, newDiceValue];
}
class RemoteRollEvent extends GameEvent {
final String playerId;
final int diceValue;
const RemoteRollEvent({required this.playerId, required this.diceValue});
@override
List<Object?> get props => [playerId, diceValue];
}
class ResetGameEvent extends GameEvent {
const ResetGameEvent();
}
// =============================================================================
// GAME STATES
// =============================================================================
enum GamePhase {
initializing,
waitingForRoll,
selectingToken,
moving,
waitingForOpponent,
gameOver,
}
enum PlayerStatus { active, disconnected, won, eliminated }
class LudoGameState extends Equatable {
final List<Player> players;
final List<Token> tokens;
final int currentPlayerIndex;
final int diceValue;
final bool hasRolledSix;
final String? selectedTokenId;
final List<int> validMoveIndices;
final GamePhase phase;
final String? winnerId;
final bool isMultiplayer;
final Map<String, PlayerStatus> playerStatuses;
const LudoGameState({
this.players = const [],
this.tokens = const [],
this.currentPlayerIndex = 0,
this.diceValue = 0,
this.hasRolledSix = false,
this.selectedTokenId,
this.validMoveIndices = const [],
this.phase = GamePhase.initializing,
this.winnerId,
this.isMultiplayer = false,
this.playerStatuses = const {},
});
Player get currentPlayer => players[currentPlayerIndex];
LudoGameState copyWith({
List<Player>? players,
List<Token>? tokens,
int? currentPlayerIndex,
int? diceValue,
bool? hasRolledSix,
String? selectedTokenId,
bool clearSelectedToken = false,
List<int>? validMoveIndices,
GamePhase? phase,
String? winnerId,
bool clearWinner = false,
bool? isMultiplayer,
Map<String, PlayerStatus>? playerStatuses,
}) {
return LudoGameState(
players: players ?? this.players,
tokens: tokens ?? this.tokens,
currentPlayerIndex: currentPlayerIndex ?? this.currentPlayerIndex,
diceValue: diceValue ?? this.diceValue,
hasRolledSix: hasRolledSix ?? this.hasRolledSix,
selectedTokenId: clearSelectedToken ? null : (selectedTokenId ?? this.selectedTokenId),
validMoveIndices: validMoveIndices ?? this.validMoveIndices,
phase: phase ?? this.phase,
winnerId: clearWinner ? null : (winnerId ?? this.winnerId),
isMultiplayer: isMultiplayer ?? this.isMultiplayer,
playerStatuses: playerStatuses ?? this.playerStatuses,
);
}
@override
List<Object?> get props => [
players, tokens, currentPlayerIndex, diceValue, hasRolledSix,
selectedTokenId, validMoveIndices, phase, winnerId, isMultiplayer,
playerStatuses,
];
}
// =============================================================================
// GAME BLOC
// =============================================================================
class GameBloc extends Bloc<GameEvent, LudoGameState> {
final Random _random = Random();
final int _tokenCountPerPlayer = 4;
GameBloc() : super(const LudoGameState()) {
on<InitializeGame>(_onInitializeGame);
on<RollDiceEvent>(_onRollDice);
on<SelectTokenEvent>(_onSelectToken);
on<MoveTokenEvent>(_onMoveToken);
on<TokenDroppedEvent>(_onTokenDropped);
on<RemoteMoveEvent>(_onRemoteMove);
on<RemoteRollEvent>(_onRemoteRoll);
on<ResetGameEvent>(_onResetGame);
}
void _onInitializeGame(InitializeGame event, Emitter<LudoGameState> emit) {
final tokens = <Token>[];
for (int p = 0; p < event.players.length; p++) {
for (int t = 0; t < _tokenCountPerPlayer; t++) {
tokens.add(Token(
id: 'p${p}_t$t',
playerIndex: p,
cellIndex: -1, // -1 means in home base
isHome: true,
isFinished: false,
));
}
}
emit(state.copyWith(
players: event.players,
tokens: tokens,
currentPlayerIndex: 0,
phase: GamePhase.waitingForRoll,
diceValue: 0,
hasRolledSix: false,
isMultiplayer: event.isMultiplayer,
playerStatuses: {
for (final p in event.players) p.id: PlayerStatus.active
},
));
}
void _onRollDice(RollDiceEvent event, Emitter<LudoGameState> emit) {
if (state.phase != GamePhase.waitingForRoll) return;
final diceValue = _random.nextInt(6) + 1;
final hasRolledSix = diceValue == 6;
// Get valid tokens that can be moved with this dice value
final validTokens = _getMovableTokens(state.tokens, diceValue);
if (validTokens.isEmpty && !hasRolledSix) {
// No valid moves and didn't roll a 6 — pass turn
final nextIndex = (state.currentPlayerIndex + 1) % state.players.length;
emit(state.copyWith(
diceValue: diceValue,
hasRolledSix: false,
currentPlayerIndex: nextIndex,
phase: GamePhase.waitingForRoll,
validMoveIndices: [],
));
} else if (validTokens.length == 1 && hasRolledSix) {
// Auto-select if only one token can move
final token = validTokens.first;
final validMoves = _calculateValidMoves(token, diceValue, state.tokens);
emit(state.copyWith(
diceValue: diceValue,
hasRolledSix: true,
selectedTokenId: token.id,
validMoveIndices: validMoves,
phase: GamePhase.selectingToken,
));
} else {
emit(state.copyWith(
diceValue: diceValue,
hasRolledSix: hasRolledSix,
phase: GamePhase.selectingToken,
validMoveIndices: [],
));
}
}
void _onSelectToken(SelectTokenEvent event, Emitter<LudoGameState> emit) {
if (state.phase != GamePhase.selectingToken) return;
final token = state.tokens.firstWhere(
(t) => t.id == event.tokenId,
orElse: () => throw StateError('Token not found: ${event.tokenId}'),
);
final validMoves = _calculateValidMoves(token, state.diceValue, state.tokens);
if (validMoves.isEmpty) return; // Token cannot move
emit(state.copyWith(
selectedTokenId: event.tokenId,
validMoveIndices: validMoves,
phase: GamePhase.moving,
));
}
void _onMoveToken(MoveTokenEvent event, Emitter<LudoGameState> emit) {
if (state.phase != GamePhase.moving) return;
final tokenIndex = state.tokens.indexWhere((t) => t.id == event.tokenId);
if (tokenIndex == -1) return;
final token = state.tokens[tokenIndex];
final targetCellIndex = event.targetCellIndex;
// Check for capture on shared path (not on safe cells)
var newTokens = List<Token>.from(state.tokens);
final isSafeCell = BoardConstants.safeCells.contains(targetCellIndex);
final isStarCell = BoardConstants.starCells.contains(targetCellIndex);
if (!isSafeCell && !isStarCell) {
final capturedIndex = newTokens.indexWhere(
(t) => t.cellIndex == targetCellIndex && t.playerIndex != token.playerIndex && !t.isFinished,
);
if (capturedIndex != -1) {
// Return captured token to home
newTokens[capturedIndex] = newTokens[capturedIndex].copyWith(
cellIndex: -1,
isHome: true,
);
}
}
// Move the token
newTokens[tokenIndex] = token.copyWith(
cellIndex: targetCellIndex,
isHome: false,
);
// Check if token reached the finish
if (BoardConstants.isFinished(token.playerIndex, targetCellIndex)) {
newTokens[tokenIndex] = newTokens[tokenIndex].copyWith(isFinished: true);
}
// Check for game winner
final currentPlayerTokens = newTokens.where((t) => t.playerIndex == state.currentPlayerIndex);
final isWinner = currentPlayerTokens.every((t) => t.isFinished);
if (isWinner) {
emit(state.copyWith(
tokens: newTokens,
selectedTokenId: null,
validMoveIndices: [],
phase: GamePhase.gameOver,
winnerId: state.currentPlayer.id,
clearSelectedToken: true,
));
return;
}
// Determine next player — roll a 6 grants another turn
int nextIndex = (state.currentPlayerIndex + 1) % state.players.length;
GamePhase nextPhase = GamePhase.waitingForRoll;
// If current player rolled a 6, they get another turn
if (state.hasRolledSix) {
nextIndex = state.currentPlayerIndex;
nextPhase = GamePhase.waitingForRoll;
}
emit(state.copyWith(
tokens: newTokens,
currentPlayerIndex: nextIndex,
phase: nextPhase,
diceValue: 0,
hasRolledSix: false,
clearSelectedToken: true,
validMoveIndices: [],
));
}
void _onTokenDropped(TokenDroppedEvent event, Emitter<LudoGameState> emit) {
// Handle token being sent back to home (capture on start cell)
if (state.diceValue != 6) return;
final opponentIndex = state.tokens.indexWhere(
(t) => t.cellIndex == BoardConstants.startCells[event.playerIndex] &&
t.playerIndex == event.playerIndex &&
t.cellIndex != -1,
);
if (opponentIndex != -1) return; // Own token on start, no capture
final capturedIndex = state.tokens.indexWhere(
(t) => t.cellIndex == BoardConstants.startCells[event.playerIndex] &&
t.playerIndex != event.playerIndex &&
!t.isFinished,
);
if (capturedIndex == -1) return;
var newTokens = List<Token>.from(state.tokens);
newTokens[capturedIndex] = newTokens[capturedIndex].copyWith(
cellIndex: -1,
isHome: true,
);
emit(state.copyWith(tokens: newTokens));
}
void _onRemoteMove(RemoteMoveEvent event, Emitter<LudoGameState> emit) {
// In multiplayer, remote moves are validated and applied
add(MoveTokenEvent(event.tokenId, event.targetCellIndex));
}
void _onRemoteRoll(RemoteRollEvent event, Emitter<LudoGameState> emit) {
// Apply remote dice value and update phase
emit(state.copyWith(
diceValue: event.diceValue,
hasRolledSix: event.diceValue == 6,
phase: GamePhase.waitingForOpponent,
));
}
void _onResetGame(ResetGameEvent event, Emitter<LudoGameState> emit) {
emit(const LudoGameState());
}
// =============================================================================
// PRIVATE HELPERS
// =============================================================================
List<Token> _getMovableTokens(List<Token> tokens, int diceValue) {
final playerTokens = tokens.where(
(t) => t.playerIndex == state.currentPlayerIndex,
);
return playerTokens.where((token) {
// Token in home base can only move out with a 6
if (token.isHome) return diceValue == 6;
// Finished tokens cannot move
if (token.isFinished) return false;
// Check if any valid move exists
return _calculateValidMoves(token, diceValue, tokens).isNotEmpty;
}).toList();
}
List<int> _calculateValidMoves(Token token, int diceValue, List<Token> allTokens) {
if (token.isFinished) return [];
if (token.isHome) {
// Can only leave home on a 6
return diceValue == 6 ? [BoardConstants.startCells[token.playerIndex]] : [];
}
final currentPos = token.cellIndex;
final playerStart = BoardConstants.playerStartIndex[token.playerIndex]!;
final targetPos = BoardConstants.getNextCell(currentPos, diceValue, token.playerIndex);
if (BoardConstants.isOutOfBounds(targetPos, token.playerIndex)) {
return [];
}
return [targetPos];
}
}
The BLoC's event handlers are pure functions — each takes the current state and produces a new state without side effects. This makes the entire game logic unit-testable: you dispatch an event, assert the resulting state, and verify game rules are enforced correctly. For example, you can write a test that verifies a token can only exit the home base when the dice shows a 6, or that a capture is triggered when landing on an opponent's token on a non-safe cell.
For multiplayer scenarios, the WebSocket service subscribes to remote player events and dispatches RemoteMoveEvent and RemoteRollEvent to the BLoC, ensuring the local game state stays synchronized with the server's authoritative state. This is covered in detail in the next section.
Step 5 — WebSocket Service with web_socket_channel
Real-time multiplayer Ludo requires a bidirectional communication channel between the game client and the server. The web_socket_channel package provides a pure Dart WebSocket client that works on both iOS and Android without native dependencies. For the server side, the Ludo Realtime API exposes a Socket.IO endpoint that is compatible with raw WebSocket connections when you configure the client appropriately.
The WebSocketService manages the connection lifecycle, automatic reconnection with exponential backoff, room management, and event dispatching to the GameBloc:
import 'dart:async';
import 'dart:convert';
import 'package:web_socket_channel/web_socket_channel.dart';
enum ConnectionStatus { disconnected, connecting, connected, reconnecting }
class WebSocketService {
final String serverUrl;
final String roomId;
final String playerId;
final Function(Map<String, dynamic>) onGameEvent;
final Function(ConnectionStatus) onStatusChange;
WebSocketChannel? _channel;
StreamSubscription? _subscription;
Timer? _reconnectTimer;
Timer? _pingTimer;
int _reconnectAttempts = 0;
static const int maxReconnectAttempts = 10;
static const Duration baseReconnectDelay = Duration(seconds: 1);
ConnectionStatus _status = ConnectionStatus.disconnected;
ConnectionStatus get status => _status;
WebSocketService({
required this.serverUrl,
required this.roomId,
required this.playerId,
required this.onGameEvent,
required this.onStatusChange,
});
Future<void> connect() async {
if (_status == ConnectionStatus.connecting || _status == ConnectionStatus.connected) return;
_updateStatus(ConnectionStatus.connecting);
try {
// Build the WebSocket URL with room and player query params
final wsUrl = Uri.parse('$serverUrl?room=$roomId&player=$playerId');
_channel = WebSocketChannel.connect(wsUrl);
_subscription = _channel!.stream.listen(
_onMessage,
onError: _onError,
onDone: _onDone,
cancelOnError: false,
);
_updateStatus(ConnectionStatus.connected);
_reconnectAttempts = 0;
_startPingTimer();
// Send join room event
sendEvent({
'type': 'join_room',
'roomId': roomId,
'playerId': playerId,
'timestamp': DateTime.now().toIso8601String(),
});
} catch (e) {
_updateStatus(ConnectionStatus.disconnected);
_scheduleReconnect();
}
}
void sendEvent(Map<String, dynamic> event) {
if (_channel == null || _status != ConnectionStatus.connected) return;
try {
_channel!.sink.add(jsonEncode(event));
} catch (e) {
// Connection may have dropped; schedule reconnect
_scheduleReconnect();
}
}
void sendDiceRoll(int diceValue) {
sendEvent({
'type': 'dice_roll',
'playerId': playerId,
'value': diceValue,
'timestamp': DateTime.now().toIso8601String(),
});
}
void sendTokenMove(String tokenId, int targetCellIndex) {
sendEvent({
'type': 'token_move',
'playerId': playerId,
'tokenId': tokenId,
'targetCell': targetCellIndex,
'timestamp': DateTime.now().toIso8601String(),
});
}
void _onMessage(dynamic message) {
try {
final data = jsonDecode(message as String) as Map<String, dynamic>;
final type = data['type'] as String?;
// Ignore our own echo messages
if (data['playerId'] == playerId && type != 'game_state_sync') return;
switch (type) {
case 'dice_roll':
onGameEvent({
'event': 'remote_roll',
'playerId': data['playerId'],
'diceValue': data['value'],
});
break;
case 'token_move':
onGameEvent({
'event': 'remote_move',
'playerId': data['playerId'],
'tokenId': data['tokenId'],
'targetCell': data['targetCell'],
});
break;
case 'game_state_sync':
// Full state sync from server (used after reconnection)
onGameEvent({
'event': 'state_sync',
'state': data['state'],
});
break;
case 'player_joined':
case 'player_left':
case 'opponent_ready':
onGameEvent({'event': type, 'playerId': data['playerId']});
break;
case 'game_over':
onGameEvent({
'event': 'game_over',
'winnerId': data['winnerId'],
'scores': data['scores'],
});
break;
case 'pong':
// Server acknowledged our ping; connection is alive
break;
default:
// Pass through any custom events
onGameEvent({'event': type ?? 'unknown', ...data});
}
} catch (e) {
// Malformed message; log and continue
}
}
void _onError(Object error) {
_updateStatus(ConnectionStatus.disconnected);
_scheduleReconnect();
}
void _onDone() {
_updateStatus(ConnectionStatus.disconnected);
_scheduleReconnect();
}
void _scheduleReconnect() {
if (_reconnectAttempts >= maxReconnectAttempts) {
onStatusChange(ConnectionStatus.disconnected);
return;
}
_updateStatus(ConnectionStatus.reconnecting);
_reconnectAttempts++;
// Exponential backoff with jitter
final delay = baseReconnectDelay * (1 << _reconnectAttempts);
final jitter = Duration(milliseconds: delay.inMilliseconds ~/ 2 + _random.nextInt(delay.inMilliseconds));
_reconnectTimer = Timer(jitter, () => connect());
}
void _startPingTimer() {
_pingTimer?.cancel();
_pingTimer = Timer.periodic(const Duration(seconds: 25), (_) {
sendEvent({'type': 'ping', 'timestamp': DateTime.now().toIso8601String()});
});
}
void _updateStatus(ConnectionStatus newStatus) {
_status = newStatus;
onStatusChange(newStatus);
}
Future<void> disconnect() async {
_reconnectTimer?.cancel();
_pingTimer?.cancel();
await _subscription?.cancel();
await _channel?.sink.close();
_channel = null;
_updateStatus(ConnectionStatus.disconnected);
}
void dispose() {
disconnect();
}
}
// Stateless helper for random jitter in reconnect delay
class _Random {
final _delegate = DateTime.now().microsecondsSinceEpoch;
int nextInt(int max) => _delegate % max;
}
final _random = _Random();
The WebSocket service handles the full connection lifecycle including exponential backoff reconnection, ping/pong heartbeat monitoring, and event routing to the BLoC. When the connection drops, the service automatically schedules a reconnection attempt with exponential backoff and jitter to avoid thundering herd problems. Upon reconnection, it sends a join_room event to re-establish the player's position in the game room and receives a game_state_sync response from the server that reconciles the local state with the authoritative server state.
Integrate this service with the BLoC by creating it at the screen level and passing event callbacks into the constructor. When the WebSocket service receives a remote move, it calls onGameEvent which dispatches the corresponding RemoteMoveEvent or RemoteRollEvent to the GameBloc. When the local player rolls dice or moves a token, call sendDiceRoll or sendTokenMove to broadcast the action to other players.
Step 6 — Firebase Push Notifications
Push notifications keep players engaged by alerting them when it is their turn, when an opponent has made a move, when a friend has invited them to a game, or when a tournament is about to start. Firebase Cloud Messaging (FCM) provides a reliable, battery-efficient push notification service that works across iOS and Android. The firebase_messaging package handles the Flutter side of FCM integration.
The NotificationService below manages FCM token registration, foreground message handling, background message handling, and notification display with deep-link payloads:
import 'dart:async';
import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
final _messageController = StreamController<RemoteMessage>.broadcast();
Stream<RemoteMessage> get onMessage => _messageController.stream;
/// Initialize FCM: request permission, register handlers, get token
Future<void> initialize() async {
// Request notification permission (required on iOS 13+)
final settings = await _messaging.requestPermission(
alert: true,
announcement: false,
badge: true,
carPlay: false,
criticalAlert: false,
provisional: false,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional) {
// Get FCM registration token
final token = await _messaging.getToken();
if (token != null) {
await _sendTokenToServer(token);
}
// Listen for token refresh events
_messaging.onTokenRefresh.listen(_sendTokenToServer);
// --- Foreground message handler ---
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
_handleMessage(message);
});
// --- Background/terminated message handler ---
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
// --- Handle notification tap when app is in background ---
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_handleMessageOpen(message);
});
// --- Check if app was opened from a terminated state via notification ---
final initialMessage = await _messaging.getInitialMessage();
if (initialMessage != null) {
_handleMessageOpen(initialMessage);
}
}
}
/// Send the FCM token to your backend so it can target this device
Future<void> _sendTokenToServer(String token) async {
// TODO: POST to your server's FCM token registration endpoint
// await http.post(
// Uri.parse('https://api.ludokingapi.site/fcm/register'),
// headers: {'Content-Type': 'application/json'},
// body: jsonEncode({'token': token, 'playerId': playerId}),
// );
}
void _handleMessage(RemoteMessage message) {
// Emit to stream for active game screens to handle
_messageController.add(message);
// For local notifications while app is in foreground
if (message.notification != null) {
// Show a custom in-app notification banner
_showInAppNotification(message);
}
}
void _handleMessageOpen(RemoteMessage message) {
// Parse deep-link payload from the notification data
final data = message.data;
final gameRoomId = data['roomId'] as String?;
final action = data['action'] as String?; // 'your_turn', 'invite', 'tournament'
if (gameRoomId != null) {
// Navigate to the game room via deep link service
// DeepLinkService().navigateToRoom(gameRoomId, action: action);
}
_messageController.add(message);
}
void _showInAppNotification(RemoteMessage message) {
// Emit a BLoC event or update a notification overlay state
// Example: context.read<NotificationBloc>().add(ShowNotification(message));
}
/// Subscribe to a topic (e.g., 'room_$roomId' for room-specific notifications)
Future<void> subscribeToTopic(String topic) async {
await _messaging.subscribeToTopic(topic);
}
Future<void> unsubscribeFromTopic(String topic) async {
await _messaging.unsubscribeFromTopic(topic);
}
}
/// Must be a top-level or static function for background message handling
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// Handle background message — update game state, show local notification
final notificationService = NotificationService();
notificationService._handleMessage(message);
}
On iOS, you must add push notification capabilities in Xcode (Signing & Capabilities → Push Notifications) and upload an APNs authentication key to the Firebase console. On Android, FCM works out of the box after placing google-services.json in your android/app/ directory. The onBackgroundMessage handler is invoked when a notification arrives while the app is not in the foreground — this is where you update local game state or display a local notification using the flutter_local_notifications package.
Structure your FCM payload with both a notification block (for the system notification tray) and a data block (for deep-link routing). The data.roomId field tells the app which game room to navigate to, and data.action tells it what action triggered the notification.
Step 7 — App Links Deep Linking
Deep linking lets players join a game room directly from a URL, a WhatsApp message, an email invitation, or a push notification tap — without navigating through the app's home screen. Flutter supports two deep linking mechanisms: App Links (Android) and Universal Links (iOS), both of which use the uni_links package for a unified API.
On Android, add an intent filter in android/app/src/main/AndroidManifest.xml to declare the App Link domain:
<!-- android/app/src/main/AndroidManifest.xml -->
<!-- Add inside <application> tag -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Associate https://ludokingapi.site/join/{roomId} with this app -->
<data
android:scheme="https"
android:host="ludokingapi.site"
android:pathPrefix="/join" />
</intent-filter>
<!-- Also support a custom scheme for testing -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="ludogame" />
</intent-filter>
On iOS, configure associated domains in Xcode (Signing & Capabilities → Associated Domains) with applinks:ludokingapi.site. You also need to host an apple-app-site-association file at https://ludokingapi.site/.well-known/apple-app-site-association. For Flutter, the uni_links package listens for incoming links and provides a stream of parsed URIs.
import 'dart:async';
import 'package:uni_links/uni_links.dart';
class DeepLinkService {
static final DeepLinkService _instance = DeepLinkService._internal();
factory DeepLinkService() => _instance;
DeepLinkService._internal();
final _deepLinkController = StreamController<DeepLink>.broadcast();
Stream<DeepLink> get onDeepLink => _deepLinkController.stream;
StreamSubscription? _linkSubscription;
/// Start listening for incoming deep links
Future<void> initialize() async {
// Handle links when app is already running
_linkSubscription = uriLinkStream.listen(
(Uri? uri) {
if (uri != null) {
_handleUri(uri);
}
},
onError: (err) {
// Invalid URI format or link resolution failure
},
);
// Handle the initial link (app launched from a link)
final initialUri = await getInitialUri();
if (initialUri != null) {
_handleUri(initialUri);
}
}
void _handleUri(Uri uri) {
// Supported formats:
// https://ludokingapi.site/join/{roomId}
// ludogame://join/{roomId}
// ludogame://invite/{playerId}
final pathSegments = uri.pathSegments;
if (pathSegments.isEmpty) return;
switch (pathSegments[0]) {
case 'join':
if (pathSegments.length >= 2) {
final roomId = pathSegments[1];
_deepLinkController.add(DeepLink.joinRoom(roomId));
}
break;
case 'invite':
if (pathSegments.length >= 2) {
final playerId = pathSegments[1];
_deepLinkController.add(DeepLink.friendInvite(playerId));
}
break;
case 'tournament':
if (pathSegments.length >= 2) {
final tournamentId = pathSegments[1];
_deepLinkController.add(DeepLink.tournament(tournamentId));
}
break;
default:
_deepLinkController.add(DeepLink.unknown(uri.toString()));
}
}
void dispose() {
_linkSubscription?.cancel();
_deepLinkController.close();
}
}
sealed class DeepLink {
const DeepLink();
const factory DeepLink.joinRoom(String roomId) = JoinRoomLink;
const factory DeepLink.friendInvite(String playerId) = FriendInviteLink;
const factory DeepLink.tournament(String tournamentId) = TournamentLink;
const factory DeepLink.unknown(String uri) = UnknownLink;
}
class JoinRoomLink extends DeepLink {
final String roomId;
const JoinRoomLink(this.roomId);
}
class FriendInviteLink extends DeepLink {
final String playerId;
const FriendInviteLink(this.playerId);
}
class TournamentLink extends DeepLink {
final String tournamentId;
const TournamentLink(this.tournamentId);
}
class UnknownLink extends DeepLink {
final String uri;
const UnknownLink(this.uri);
}
In your GameScreen, subscribe to the deep link stream and dispatch the appropriate BLoC event when a player joins via link. For example, when a player taps a WhatsApp invitation link, the app opens to the multiplayer lobby with the roomId pre-filled, ready to join. For tournament links, navigate directly to the tournament bracket view.
You also need to host the apple-app-site-association file on your server. This JSON file tells iOS that links to https://ludokingapi.site/join/* should open your app rather than the browser. The equivalent on Android is the assetlinks.json file hosted at https://ludokingapi.site/.well-known/assetlinks.json.
Step 8 — Building for iOS and Android
With all components in place, build your Ludo game for target platforms. Run flutter analyze to check for static analysis issues before building — pay attention to unused imports, missing null checks, and type mismatches in the BLoC event handlers. Then run flutter test to execute unit tests on your game logic (movement rules, capture logic, victory conditions), BLoC tests (event-to-state transitions), and repository tests.
# Run static analysis
flutter analyze
# Run unit tests (especially BLoC and game engine tests)
flutter test
# Build for iOS simulator
flutter build ios --simulator --no-codesign
# Build for iOS App Store (requires Apple Developer account)
flutter build ios --release
# Build for Android (debug APK for testing)
flutter build apk --debug
# Build for Android (release APK)
flutter build apk --release
# Build for Android App Bundle (Play Store submission)
flutter build appbundle --release
For iOS App Store submission, you need an App Store Connect account, a properly configured bundle identifier, and code signing certificates. Use Fastlane to automate the build, code signing, and submission pipeline. For Android, generate a signed release APK or AAB (Android App Bundle) using your keystore, then upload to Google Play Console. The Docker Deployment Guide covers containerizing your backend for production hosting.
Before publishing, run a performance profile on both platforms using Flutter DevTools. Check the GPU rendering timeline for any CustomPainter bottlenecks — the shouldRepaint implementation should prevent unnecessary redraws during dice animations or token movement.
Frequently Asked Questions
CustomPainter draws the entire board on a single Canvas layer, making it dramatically more efficient than using stacks of Positioned or GridView widgets. Each widget in Flutter's render tree has overhead for layout, painting, and compositing — with a 52-cell Ludo board plus home bases, tokens, and markers, hundreds of widget nodes would cause measurable frame drops on mid-range devices. CustomPainter consolidates all drawing into one efficient paint() call. Additionally, the coordinate-based approach makes geometric calculations (cell positions, token centers, path highlighting) straightforward and resolution-independent.
The GameBloc receives remote player actions as RemoteMoveEvent and RemoteRollEvent dispatched by the WebSocketService. These events are processed identically to local player events — the BLoC's event handlers validate the action against game rules and emit the new state. This means the same code path handles both local and remote moves, eliminating duplication and ensuring consistent rule enforcement. The server acts as the authoritative source of truth, broadcasting moves to all connected clients, which dispatch them as events to their local BLoC instances.
Use web_socket_channel for raw WebSocket connections and socket_io_client when your server uses the Socket.IO protocol with its custom handshake, heartbeat, and fallback mechanisms. The Ludo Realtime API supports raw WebSocket connections — web_socket_channel is lighter, has fewer transitive dependencies, and works well for standard game synchronization. Use socket_io_client if your backend is a Node.js Socket.IO server that requires the protocol's built-in features like automatic reconnection, room broadcasting, or binary message support.
Wrap the LudoBoard widget in a BlocBuilder<GameBloc, LudoGameState> that triggers an AnimationController whenever state.selectedTokenId or state.validMoveIndices changes. For token movement animations, use the token's start and end cell coordinates from BoardConstants to interpolate position over 300ms with a SpringCurve or Curves.easeOutCubic. The flutter_animate package simplifies multi-step animation sequences — chain a dice tumble (600ms), token slide (300ms), capture bounce-back (400ms), and turn indicator fade (200ms) into a cohesive sequence that plays automatically after each state transition.
When the app is terminated, the OS delivers FCM push notifications directly to the system notification tray — your Dart code is not running. When the user taps the notification, the OS launches the app and passes the notification payload to the onMessageOpenedApp stream (if app was in background) or the getInitialMessage() future (if app was terminated). In the background case, the onBackgroundMessage handler is also invoked before the app launches, giving you a chance to update local state or show a local notification. For iOS, ensure background modes for remote notifications are enabled in Xcode capabilities. The NotificationService implementation in this tutorial handles all three app lifecycle states correctly.
Both mechanisms verify domain ownership before routing links to the app. On Android, you host an assetlinks.json file at https://ludokingapi.site/.well-known/assetlinks.json containing your app's package name and SHA-256 signing certificate fingerprint. The Android system verifies this file during app installation or first link tap. On iOS, you host an apple-app-site-association file at https://ludokingapi.site/.well-known/apple-app-site-association containing your Team ID and Bundle ID. Both files are plain JSON and are served without redirects. The uni_links package abstracts the Flutter-side URI parsing for both platforms. The deep link service implementation in this tutorial handles both the https://ludokingapi.site/join/{roomId} URL format and the ludogame://join/{roomId} custom scheme.
Yes — create an AIPlayerAgent class that dispatches RollDiceEvent and SelectTokenEvent to the GameBloc after a configurable delay (typically 800–1500ms to feel natural). Implement AI decision logic using a priority system: first, move tokens out of home base when rolling a 6; second, capture opponent tokens on non-safe cells; third, move tokens into home columns; fourth, advance the token closest to finishing. For stronger AI, implement a minimax search with a depth of 3–4 plies, evaluating positions by token safety, proximity to home, and star cell control. The AI agent operates entirely through the BLoC interface, so no game logic changes are needed to add AI opponents to either local or multiplayer games.
Need Help Building Your Ludo Game?
From custom board designs to full multiplayer backend integration, the Ludo King API team has the expertise to bring your Flutter Ludo game to market. Get a free consultation over WhatsApp.
Chat on WhatsApp