Ludo Flutter Tutorial — Build a Cross-Platform Ludo Game with BLoC & WebSockets

📅 Updated March 21, 2026 ⏱️ 25 min read 📱 Flutter • Dart • BLoC • CustomPainter • WebSockets

Talk to Us on WhatsApp

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 flutter on macOS or download from flutter.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.com for 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