What You'll Build

This tutorial builds a complete Ludo game app for Android and iOS from a single React Native codebase. The app features a lobby where players create or join rooms, a real-time game board with four players, animated dice rolling with physics-based spring animations, push notifications for turn alerts and opponent captures, and full offline support via AsyncStorage so players can review game history without an internet connection. The backend integration uses the Ludo Realtime API for WebSocket communication and the Android API platform page for mobile-specific configuration.

Step 1 — Expo Project Setup

Expo is the recommended way to build React Native apps for Ludo games because it eliminates native build configuration complexity while still supporting custom native modules via the development build. We will use Expo with the TypeScript template and install the required peer dependencies.

Bash
# Create a new Expo app with TypeScript (blank template includes it)
npx create-expo-app@latest LudoGame --template blank-typescript
cd LudoGame

# Install core dependencies for navigation, networking, storage, and animations
npx expo install @react-navigation/native @react-navigation/native-stack \
  react-native-screens react-native-safe-area-context \
  socket.io-client @react-native-async-storage/async-storage \
  react-native-reanimated expo-device

# Install Firebase for push notifications (requires custom dev client)
npx expo install expo-notifications expo-build-properties
npx expo install firebase

# Install gesture support for swipe-based dice rolling
npx expo install react-native-gesture-handler react-native-haptic-feedback

# Verify package.json dependencies
cat package.json

After installation, configure app.json to enable the necessary Expo features and prepare for EAS Build:

JSON
// app.json
{
  "expo": {
    "name": "LudoGame",
    "slug": "ludo-game",
    "version": "1.0.0",
    "orientation": "portrait",
    "icon": "./assets/icon.png",
    "userInterfaceStyle": "light",
    "newArchEnabled": true,
    "splash": {
      "image": "./assets/splash-icon.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "ios": {
      "supportsTablet": true,
      "bundleIdentifier": "com.ludokingapi.ludogame",
      "infoPlist": {
        "UIBackgroundModes": ["remote-notification"]
      }
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/adaptive-icon.png",
        "backgroundColor": "#ffffff"
      },
      "package": "com.ludokingapi.ludogame",
      "permissions": [
        "VIBRATE",
        "RECEIVE_BOOT_COMPLETED"
      ],
      "useNextNotificationsApi": true
    },
    "plugins": [
      "expo-build-properties",
      [
        "expo-notifications",
        {
          "icon": "./assets/notification-icon.png",
          "color": "#FF6B00",
          "sounds": []
        }
      ]
    ]
  }
}

Step 2 — Project Directory Structure

A well-structured directory is essential for maintaining a game app as features grow. Separate concerns across layers: types define the data model, hooks encapsulate state and side effects, components handle rendering, screens coordinate views, services handle networking and storage, and constants define the static board geometry.

Text
src/
├── App.tsx                    # Root with NavigationContainer
├── types/
│   └── game.ts                # All TypeScript interfaces for game state
├── constants/
│   ├── boardConfig.ts         # Board geometry, cell positions, safe squares
│   └── gameRules.ts           # Move validation constants
├── hooks/
│   ├── useGameSocket.ts        # WebSocket connection, reconnection, event dispatch
│   ├── useGameState.ts         # Local game state machine
│   ├── useNotifications.ts    # Firebase Cloud Messaging setup and handlers
│   └── useOfflineStorage.ts    # AsyncStorage read/write for offline game history
├── components/
│   ├── Board.tsx              # Full Ludo board with cell grid
│   ├── Cell.tsx               # Individual cell (track, home, safe, star)
│   ├── Token.tsx              # Player token with animated movement
│   ├── Dice.tsx               # Animated 3D-ish dice with tap-to-roll
│   ├── PlayerPanel.tsx        # Current player indicator and status
│   └── GameHeader.tsx         # Room code, player list, timer
├── screens/
│   ├── HomeScreen.tsx         # Lobby: create room, join room, history
│   ├── GameScreen.tsx         # Main gameplay screen
│   └── ResultScreen.tsx       # Game over, winner announcement, stats
├── services/
│   ├── socketService.ts       # Socket.IO singleton with typed events
│   ├── apiService.ts          # REST API calls (create game, join, stats)
│   └── storageService.ts      # AsyncStorage wrapper for game history
└── utils/
    ├── boardHelpers.ts         # Move calculation, path finding, capture detection
    └── formatters.ts          # Time, score, and display formatting

Step 3 — TypeScript Game Types

Define all game-related types in one place. Strong typing eliminates runtime errors from mismatched data shapes and makes the codebase self-documenting. These types mirror the Pydantic models from the FastAPI backend, ensuring the client and server speak the same language.

TypeScript
// src/types/game.ts
export enum PlayerColor { RED = 0, GREEN = 1, BLUE = 2, YELLOW = 3 }

export enum GamePhase { WAITING = 'waiting', ROLLING = 'rolling', MOVING = 'moving', GAME_OVER = 'finished' }

export interface Token {
  player: PlayerColor;
  square: number;       // -1 = home base, 0-51 = track, 52-56 = home column
  finished: boolean;
  tokenIndex: number;  // 0-3 within player's set
}

export interface Player {
  playerId: string;
  color: PlayerColor;
  displayName: string;
  connected: boolean;
  finishedTokens: number;
}

export interface GameState {
  gameId: string;
  players: Player[];
  currentPlayerIndex: number;
  diceValue: number | null;
  tokens: Token[];
  phase: GamePhase;
  winner: PlayerColor | null;
  moveCount: number;
}

export interface GameRoom {
  roomCode: string;
  hostId: string;
  players: Player[];
  maxPlayers: number;
}

export interface SocketEvents {
  'game-start': (state: GameState) => void;
  'state-update': (state: GameState) => void;
  'player-joined': (player: Player) => void;
  'player-left': (playerId: string) => void;
  'opponent-roll': (data: { playerId: string; diceValue: number }) => void;
  'opponent-move': (data: MoveData) => void;
  'game-over': (data: { winner: PlayerColor }) => void;
  'error': (data: { message: string }) => void;
}

export interface MoveData {
  playerId: string;
  tokenIndex: number;
  fromSquare: number;
  toSquare: number;
  captured: boolean;
  capturedTokenIndex?: number;
  finished: boolean;
}

export interface SavedGame {
  gameId: string;
  finishedAt: string;
  players: string[];         // display names
  winner: PlayerColor;
  moveCount: number;
  state: GameState;
}

Step 4 — WebSocket Hook (useGameSocket)

The useGameSocket hook manages the Socket.IO connection lifecycle: connecting on mount, reconnecting with exponential backoff, subscribing to typed server events, and exposing send functions for game actions. It returns the current GameState, connection status, and typed event callbacks. The hook integrates with the Ludo Realtime API and handles all reconnection logic transparently.

TypeScript
// src/hooks/useGameSocket.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { GameState, GamePhase, PlayerColor, MoveData } from '../types/game';
import { Platform } from 'react-native';

const SOCKET_URL = process.env.EXPO_PUBLIC_SOCKET_URL
  ?? 'https://api.ludokingapi.site';

interface UseGameSocketOptions {
  gameId: string;
  playerId: string;
  autoConnect?: boolean;
}

interface UseGameSocketResult {
  socket: Socket | null;
  connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error';
  gameState: GameState | null;
  sendRoll: () => void;
  sendMove: (tokenIndex: number) => void;
  subscribe: (event: K, handler: (data: GameState[K]) => void) => void;
}

export function useGameSocket({
  gameId,
  playerId,
  autoConnect = true,
}: UseGameSocketOptions): UseGameSocketResult {
  const socketRef = useRef<Socket | null>(null);
  const [connectionStatus, setConnectionStatus] = useState<UseGameSocketResult['connectionStatus']>(
    'disconnected'
  );
  const [gameState, setGameState] = useState<GameState | null>(null);
  const eventHandlers = useRef<Map<string, Set<(data: unknown) => void>>>(new Map());

  const connect = useCallback(() => {
    if (socketRef.current?.connected) return;

    setConnectionStatus('connecting');
    const socket = io(SOCKET_URL, {
      auth: { playerId, gameId },
      transports: ['websocket'],
      reconnection: true,
      reconnectionAttempts: 10,
      reconnectionDelay: 1000,
      reconnectionDelayMax: 8000,
      timeout: 10000,
      extraHeaders: Platform.OS === 'ios' ? {} : {},
    });

    socket.on('connect', () => {
      setConnectionStatus('connected');
      socket.emit('join-room', { gameId, playerId });
    });

    socket.on('disconnect', (reason) => {
      setConnectionStatus('disconnected');
      if (reason === 'io server disconnect') {
        socket.connect();  // Server initiated disconnect — reconnect manually
      }
    });

    socket.on('connect_error', () => {
      setConnectionStatus('error');
    });

    socket.on('state-update', (state: GameState) => {
      setGameState(state);
      eventHandlers.current.get('state')?.forEach(h => h(state));
    });

    socket.on('game-start', (state: GameState) => {
      setGameState(state);
      eventHandlers.current.get('game-start')?.forEach(h => h(state));
    });

    socket.on('opponent-roll', (data: { playerId: string; diceValue: number }) => {
      eventHandlers.current.get('opponent-roll')?.forEach(h => h(data));
    });

    socket.on('opponent-move', (data: MoveData) => {
      eventHandlers.current.get('opponent-move')?.forEach(h => h(data));
    });

    socket.on('game-over', (data: { winner: PlayerColor }) => {
      setGameState(prev => prev ? { ...prev, phase: GamePhase.GAME_OVER, winner: data.winner } : null);
      eventHandlers.current.get('game-over')?.forEach(h => h(data));
    });

    socket.on('error', (data: { message: string }) => {
      eventHandlers.current.get('error')?.forEach(h => h(data));
    });

    socketRef.current = socket;
  }, [gameId, playerId]);

  useEffect(() => {
    if (autoConnect) connect();
    return () => {
      socketRef.current?.removeAllListeners();
      socketRef.current?.disconnect();
      socketRef.current = null;
    };
  }, [connect, autoConnect]);

  const sendRoll = useCallback(() => {
    socketRef.current?.emit('roll', { gameId, playerId });
  }, [gameId, playerId]);

  const sendMove = useCallback((tokenIndex: number) => {
    socketRef.current?.emit('move', { gameId, playerId, tokenIndex });
  }, [gameId, playerId]);

  const subscribe = useCallback((
    event: string,
    handler: (data: unknown) => void
  ) => {
    if (!eventHandlers.current.has(event)) {
      eventHandlers.current.set(event, new Set());
    }
    eventHandlers.current.get(event)!.add(handler);
  }, []);

  return { socket: socketRef.current, connectionStatus, gameState, sendRoll, sendMove, subscribe };
}

Step 5 — Board Rendering Component

The Board component renders the classic 15×15 Ludo cross-shaped board using absolutely positioned View elements. Each cell is a View with a background color determined by the cell type (home, track, safe, star, home column). The Cell subcomponent handles the visual appearance of individual cells, and Token components are rendered on top with positional transforms. This approach avoids the complexity of Canvas or Skia while maintaining 60fps performance on mid-range devices.

TypeScript
// src/components/Board.tsx — Full Ludo board renderer using native Views
import React, { useMemo } from 'react';
import { View, StyleSheet, Dimensions, TouchableOpacity } from 'react-native';
import { GameState, Token, PlayerColor } from '../types/game';
import { BOARD_LAYOUT, getCellStyle, getCellPosition } from '../constants/boardConfig';

const { width } = Dimensions.get('window');
const BOARD_SIZE = width * 0.92;
const CELL_SIZE = BOARD_SIZE / 15;

interface BoardProps {
  gameState: GameState;
  currentPlayerId: string;
  highlightedTokenIndices: number[];
  onTokenPress: (tokenIndex: number) => void;
  onCellPress: (square: number) => void;
  movableTokenIndices: number[];
}

const PLAYER_COLORS: Record<PlayerColor, string> = {
  [PlayerColor.RED]: '#E53935',
  [PlayerColor.GREEN]: '#43A047',
  [PlayerColor.BLUE]: '#1E88E5',
  [PlayerColor.YELLOW]: '#FDD835',
};

interface CellProps {
  row: number;
  col: number;
  cellType: string;
  playerColor?: PlayerColor;
  size: number;
  isSafe: boolean;
  isStar: boolean;
  isHomeColumn: boolean;
  isHighlighted: boolean;
  token?: Token;
  onPress?: () => void;
  isMovable?: boolean;
}

function Cell({ row, col, cellType, playerColor, size, isSafe, isStar,
  isHomeColumn, isHighlighted, token, onPress, isMovable }: CellProps) {
  const bgColor = useMemo(() => {
    if (cellType === 'home') return PLAYER_COLORS[playerColor!] + '22';
    if (isHomeColumn) return PLAYER_COLORS[playerColor!] + '44';
    if (isStar) return '#FF9800';
    if (isSafe) return '#F5F5F5';
    if (row === 6 || row === 8) return '#EEEEEE';
    if (col === 6 || col === 8) return '#EEEEEE';
    return '#FAFAFA';
  }, [cellType, isSafe, isStar, isHomeColumn, playerColor, row, col]);

  return (
    <TouchableOpacity
      style={[
        styles.cell,
        {
          width: size, height: size,
          left: col * size, top: row * size,
          backgroundColor: bgColor,
          borderColor: isHighlighted ? '#FF6B00' : '#DDD',
          borderWidth: isHighlighted ? 2 : 0.5,
          zIndex: isMovable ? 10 : 1,
        },
      ]}
      onPress={onPress}
      disabled={!onPress}
      activeOpacity={0.7}
    >
      {isStar && <View style={[styles.starIndicator, { backgroundColor: '#FF5722' }]} />}
      {isSafe && !isStar && <View style={[styles.safeIndicator, { backgroundColor: '#FFFFFF' }]} />}
      {token && (
        <View
          style={[
            styles.token,
            {
              backgroundColor: PLAYER_COLORS[token.player],
              width: size * 0.7,
              height: size * 0.7,
              borderRadius: size * 0.35,
              borderWidth: isMovable ? 3 : 1,
              borderColor: isMovable ? '#FFFFFF' : '#33333333',
            },
          ]}
        />
      )}
    </TouchableOpacity>
  );
}

export default function Board({
  gameState,
  currentPlayerId,
  highlightedTokenIndices,
  onTokenPress,
  onCellPress,
  movableTokenIndices,
}: BoardProps) {
  const tokensAtSquare = useMemo(() => {
    const map = new Map<number, Token[]>();
    gameState.tokens.forEach(t => {
      if (t.square >= 0) {
        if (!map.has(t.square)) map.set(t.square, []);
        map.get(t.square)!.push(t);
      }
    });
    return map;
  }, [gameState.tokens]);

  return (
    <View style={[styles.boardContainer, { width: BOARD_SIZE, height: BOARD_SIZE }]}>
      {BOARD_LAYOUT.map((cell, idx) => {
        const tokensHere = tokensAtSquare.get(cell.square ?? -999) ?? [];
        const movable = movableTokenIndices.some(
          i => gameState.tokens[i]?.square === cell.square
        );
        return (
          <Cell
            key={idx}
            row={cell.row}
            col={cell.col}
            cellType={cell.type}
            playerColor={cell.color}
            size={CELL_SIZE}
            isSafe={cell.isSafe ?? false}
            isStar={cell.isStar ?? false}
            isHomeColumn={cell.isHomeColumn ?? false}
            isHighlighted={movable}
            token={tokensHere.length === 1 ? tokensHere[0] : undefined}
            onPress={cell.square !== undefined ? () => onCellPress(cell.square!) : undefined}
            isMovable={movable}
          />
        );
      })}
    </View>
  );
}

const styles = StyleSheet.create({
  boardContainer: {
    backgroundColor: '#F5DEB3',
    borderRadius: 12,
    borderWidth: 4,
    borderColor: '#8B4513',
    position: 'relative',
    alignSelf: 'center',
    elevation: 8,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 4 },
    shadowOpacity: 0.3,
    shadowRadius: 8,
  },
  cell: {
    position: 'absolute',
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 2,
  },
  token: {
    elevation: 4,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.3,
    shadowRadius: 3,
  },
  starIndicator: {
    width: 6, height: 6, borderRadius: 3,
    position: 'absolute', top: 2, right: 2,
  },
  safeIndicator: {
    width: 8, height: 8, borderRadius: 4,
    borderWidth: 1, borderColor: '#CCC',
  },
});

Step 6 — Animated Dice Component

The Dice component provides a satisfying tap-to-roll interaction with a physics-based animation using React Native Reanimated. The dice cycles through random values rapidly (50ms intervals) for 600ms before landing on the final value, creating the classic tumbling effect. Haptic feedback via expo-haptics reinforces the tactile feel on each tap.

TypeScript
// src/components/Dice.tsx — Animated dice with Reanimated and haptic feedback
import React, { useCallback } from 'react';
import { StyleSheet, View, TouchableOpacity, Text } from 'react-native';
import Animated, {
  useSharedValue, useAnimatedStyle,
  withSequence, withTiming, withSpring,
  runOnJS, Easing,
} from 'react-native-reanimated';
import * as Haptics from 'expo-haptics';

interface DiceProps {
  value: number | null;
  onRoll: () => void;
  disabled?: boolean;
  size?: number;
}

const DICE_FACES: Record<number, string[]> = {
  1: ['●'],
  2: ['●', '', '●'],
  3: ['●', '●', '●'],
  4: ['● ●', '', '● ●'],
  5: ['● ●', '●', '● ●'],
  6: ['● ●', '● ●', '● ●'],
};

export default function Dice({ value, onRoll, disabled, size = 72 }: DiceProps) {
  const rotateX = useSharedValue(0);
  const rotateY = useSharedValue(0);
  const scale = useSharedValue(1);
  const displayValue = useSharedValue(value ?? 1);

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { perspective: 800 },
      { rotateX: `\${rotateX.value}deg` },
      { rotateY: `\${rotateY.value}deg` },
      { scale: scale.value },
    ],
  }));

  const triggerHaptic = useCallback(() => {
    Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
  }, []);

  const handleRoll = useCallback(() => {
    if (disabled) return;
    runOnJS(triggerHaptic)();

    // Rapid cycling animation for tumble effect
    let count = 0;
    const interval = setInterval(() => {
      displayValue.value = Math.floor(Math.random() * 6) + 1;
      rotateX.value = withTiming(Math.random() * 360, { duration: 50 });
      rotateY.value = withTiming(Math.random() * 360, { duration: 50 });
      count++;
      if (count >= 10) {
        clearInterval(interval);
        displayValue.value = value ?? Math.floor(Math.random() * 6) + 1;
        rotateX.value = withSpring(Math.random() * 30 - 15, { damping: 10 });
        rotateY.value = withSpring(Math.random() * 30 - 15, { damping: 10 });
        scale.value = withSequence(
          withTiming(1.2, { duration: 100 }),
          withSpring(1, { damping: 8 })
        );
        runOnJS(onRoll)();
      }
    }, 60);
  }, [disabled, onRoll, triggerHaptic, value, displayValue, rotateX, rotateY, scale]);

  return (
    <View style={styles.container}>
      <TouchableOpacity onPress={handleRoll} disabled={disabled} activeOpacity={0.8}>
        <Animated.View style={[
          styles.dice,
          { width: size, height: size, borderRadius: size * 0.2 },
          animatedStyle,
        ]}>
          <Text style={[styles.diceText, { fontSize: size * 0.35 }]}>
            {value != null ? DICE_FACES[value].join('\n') : ''}
          </Text>
        </Animated.View>
      </TouchableOpacity>
      {disabled && <Text style={styles.hint}>Roll the dice</Text>}
      {!disabled && value && <Text style={styles.hint}>Tap to roll</Text>}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { alignItems: 'center' },
  dice: {
    backgroundColor: '#FFFFFF',
    borderWidth: 2,
    borderColor: '#8B4513',
    justifyContent: 'center',
    alignItems: 'center',
    elevation: 6,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 3 },
    shadowOpacity: 0.25,
    shadowRadius: 5,
  },
  diceText: { color: '#333', fontWeight: '700', textAlign: 'center' },
  hint: { marginTop: 8, color: '#666', fontSize: 14 },
});

Step 7 — Offline Storage with AsyncStorage

AsyncStorage provides persistent local storage for game history, settings, and cached game states. The useOfflineStorage hook wraps all AsyncStorage operations with error handling and TypeScript types. It automatically persists the last 20 completed games, player settings, and the current in-progress game state so players can resume after app termination.

TypeScript
// src/hooks/useOfflineStorage.ts
import { useCallback, useEffect } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SavedGame, GameState, PlayerColor } from '../types/game';

const STORAGE_KEYS = {
  GAME_HISTORY: '@ludo/game_history',
  CURRENT_GAME: '@ludo/current_game',
  PLAYER_SETTINGS: '@ludo/player_settings',
  NOTIFICATION_TOKEN: '@ludo/notification_token',
} as const;

const MAX_HISTORY = 20;

interface PlayerSettings {
  displayName: string;
  soundEnabled: boolean;
  hapticsEnabled: boolean;
  notificationsEnabled: boolean;
}

interface UseOfflineStorageResult {
  saveCurrentGame: (state: GameState) => Promise<void>;
  loadCurrentGame: () => Promise<GameState | null>;
  clearCurrentGame: () => Promise<void>;
  saveGameToHistory: (game: SavedGame) => Promise<void>;
  loadGameHistory: () => Promise<SavedGame[]>;
  saveSettings: (settings: PlayerSettings) => Promise<void>;
  loadSettings: () => Promise<PlayerSettings | null>;
}

export function useOfflineStorage(): UseOfflineStorageResult {
  const _get = useCallback(async <T>(key: string): Promise<T | null> => {
    try {
      const raw = await AsyncStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : null;
    } catch (err) {
      console.error(`[AsyncStorage] Failed to read \${key}:`, err);
      return null;
    }
  }, []);

  const _set = useCallback(async (key: string, value: unknown): Promise<boolean> => {
    try {
      await AsyncStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (err) {
      console.error(`[AsyncStorage] Failed to write \${key}:`, err);
      return false;
    }
  }, []);

  const saveCurrentGame = useCallback(async (state: GameState): Promise<void> => {
    await _set(STORAGE_KEYS.CURRENT_GAME, state);
  }, [_set]);

  const loadCurrentGame = useCallback(async (): Promise<GameState | null> => {
    return _get<GameState>(STORAGE_KEYS.CURRENT_GAME);
  }, [_get]);

  const clearCurrentGame = useCallback(async (): Promise<void> => {
    await AsyncStorage.removeItem(STORAGE_KEYS.CURRENT_GAME);
  }, []);

  const saveGameToHistory = useCallback(async (game: SavedGame): Promise<void> => {
    const history = await _get<SavedGame[]>(STORAGE_KEYS.GAME_HISTORY) ?? [];
    const updated = [game, ...history].slice(0, MAX_HISTORY);
    await _set(STORAGE_KEYS.GAME_HISTORY, updated);
  }, [_get, _set]);

  const loadGameHistory = useCallback(async (): Promise<SavedGame[]> => {
    return _get<SavedGame[]>(STORAGE_KEYS.GAME_HISTORY) ?? [];
  }, [_get]);

  const saveSettings = useCallback(async (settings: PlayerSettings): Promise<void> => {
    await _set(STORAGE_KEYS.PLAYER_SETTINGS, settings);
  }, [_set]);

  const loadSettings = useCallback(async (): Promise<PlayerSettings | null> => {
    return _get<PlayerSettings>(STORAGE_KEYS.PLAYER_SETTINGS);
  }, [_get]);

  return {
    saveCurrentGame, loadCurrentGame, clearCurrentGame,
    saveGameToHistory, loadGameHistory, saveSettings, loadSettings,
  };
}

Step 8 — Firebase Push Notifications

Firebase Cloud Messaging (FCM) via the Expo Notifications API delivers push notifications for turn alerts, opponent captures, game invitations, and tournament updates. The useNotifications hook registers the device with FCM, requests user permission, stores the token for targeted messaging, and handles foreground and background notification receipts.

TypeScript
// src/hooks/useNotifications.ts — Firebase Cloud Messaging via Expo Notifications
import { useEffect, useCallback, useRef } from 'react';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
import { apiService } from '../services/apiService';

// Configure notification behavior: no sound when in foreground
Notifications.setNotificationHandler({
  handleNotification: async () => ({
    shouldShowAlert: true,
    shouldPlaySound: true,
    shouldSetBadge: true,
    shouldShowBanner: true,
    shouldShowList: true,
  }),
});

interface NotificationPayload {
  gameId?: string;
  playerName?: string;
  action?: 'your_turn' | 'capture' | 'game_invite' | 'game_over';
  [key: string]: unknown;
}

export function useNotifications(playerId: string | null) {
  const notificationListenerRef = useRef<Notifications.EventSubscription | null>(null);
  const responseListenerRef = useRef<Notifications.EventSubscription | null>(null);

  const registerForPush = useCallback(async (): Promise<string | null> => {
    if (!playerId) return null;

    // Request permission (iOS prompts user, Android grants automatically)
    const { status: existingStatus } = await Notifications.getPermissionsAsync();
    let finalStatus = existingStatus;
    if (existingStatus !== 'granted') {
      const { status } = await Notifications.requestPermissionsAsync();
      finalStatus = status;
    }
    if (finalStatus !== 'granted') {
      console.warn('Push notification permission denied');
      return null;
    }

    // Get FCM push token (Expo Push Token, works with FCM behind the scenes)
    const tokenData = await Notifications.getExpoPushTokenAsync({
      projectId: 'your-expo-project-id',  // From app.json extra.expoviteProjectId
    });
    const pushToken = tokenData.data;

    // Send token to backend so the server can target this device
    try {
      await apiService.registerPushToken(playerId, pushToken);
    } catch (err) {
      console.error('Failed to register push token with backend', err);
    }

    return pushToken;
  }, [playerId]);

  const handleIncomingNotification = useCallback(
    (response: Notifications.NotificationResponse) => {
      const data = response.notification.request.content.data as NotificationPayload;
      if (data.action === 'your_turn' && data.gameId) {
        // Navigate to the game screen — handled by the navigation ref passed in
        console.log(`It's your turn in game \${data.gameId}!`);
      } else if (data.action === 'game_invite') {
        console.log(`\${data.playerName} invited you to play!`);
      }
    },
    []
  );

  useEffect(() => {
    registerForPush();

    // Handle notifications received while the app is foregrounded
    notificationListenerRef.current = Notifications.addNotificationReceivedListener(
      (notification) => {
        console.log('Notification received:', notification.request.content.title);
      }
    );

    // Handle notification tap
    responseListenerRef.current = Notifications.addNotificationResponseReceivedListener(
      handleIncomingNotification
    );

    return () => {
      notificationListenerRef.current?.remove();
      responseListenerRef.current?.remove();
    };
  }, [registerForPush, handleIncomingNotification]);

  const scheduleTurnReminder = useCallback(
    async (gameId: string, delaySeconds: number = 30) => {
      await Notifications.scheduleNotificationAsync({
        content: {
          title: '🎲 Your turn in Ludo!',
          body: 'You have 30 seconds left to make your move.',
          data: { gameId, action: 'your_turn' },
          sound: true,
        },
        trigger: { seconds: delaySeconds, type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL },
      });
    },
    []
  );

  const cancelAllScheduled = useCallback(async () => {
    await Notifications.cancelAllScheduledNotificationsAsync();
  }, []);

  return { registerForPush, scheduleTurnReminder, cancelAllScheduled };
}

Step 9 — App.tsx with Navigation

The root App.tsx wires together React Navigation, the notification handler, and the offline storage provider. It uses a NavigationContainer with a NativeStackNavigator to manage the screen stack. The stack includes the HomeScreen for lobby and room management, the GameScreen for active gameplay, and the ResultScreen for post-game stats.

TypeScript
// src/App.tsx — Root component with React Navigation, notifications, and offline storage
import React, { useEffect, useState } from 'react';
import { StatusBar } from 'expo-status-bar';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { View, ActivityIndicator, StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

import HomeScreen from './screens/HomeScreen';
import GameScreen from './screens/GameScreen';
import ResultScreen from './screens/ResultScreen';
import { useNotifications } from './hooks/useNotifications';
import { useOfflineStorage } from './hooks/useOfflineStorage';
import { GameState } from './types/game';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';

export type RootStackParamList = {
  Home: undefined;
  Game: { gameId: string; playerId: string; playerName: string };
  Result: { gameId: string; winner: number; players: string[]; moveCount: number };
};

const Stack = createNativeStackNavigator<RootStackParamList>();

export default function App() {
  const [isReady, setIsReady] = useState(false);
  const { saveCurrentGame, loadCurrentGame, clearCurrentGame } = useOfflineStorage();
  const [playerId] = useState(() => `p_\${Date.now().toString(36)}`);
  useNotifications(playerId);

  useEffect(() => {
    async function bootstrap() {
      // Check for an interrupted in-progress game and offer to resume
      const savedGame = await loadCurrentGame();
      if (savedGame && savedGame.phase !== 'finished') {
        console.log('Found interrupted game:', savedGame.gameId);
      }
      setIsReady(true);
    }
    bootstrap();
  }, [loadCurrentGame]);

  if (!isReady) {
    return (
      <View style={styles.loading}>
        <ActivityIndicator size="large" color="#FF6B00" />
      </View>
    );
  }

  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <SafeAreaProvider>
        <NavigationContainer>
          <StatusBar style="dark" />
          <Stack.Navigator
            initialRouteName="Home"
            screenOptions={{
              headerStyle: { backgroundColor: '#FF6B00' },
              headerTintColor: '#FFF',
              headerTitleStyle: { fontWeight: '700' },
              animation: 'slide_from_right',
            }}
          >
            <Stack.Screen
              name="Home"
              component={HomeScreen}
              options={{ title: 'Ludo — Multiplayer', headerShown: false }}
              initialParams={{ playerId } as never}
            />
            <Stack.Screen
              name="Game"
              component={GameScreen}
              options={({ route }) => ({
                title: `Room: \${route.params.gameId.slice(-8).toUpperCase()}`,
                headerBackTitle: 'Leave',
                gestureEnabled: false,
              })}
            />
            <Stack.Screen
              name="Result"
              component={ResultScreen}
              options={{
                title: 'Game Over',
                headerBackVisible: false,
                gestureEnabled: false,
              }}
            />
          </Stack.Navigator>
        </NavigationContainer>
      </SafeAreaProvider>
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  loading: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#FAFAFA' },
});

Step 10 — GameScreen and Integration

The GameScreen ties all the components and hooks together. It renders the board, dice, and player panel, connects to the WebSocket server, handles turn logic, persists state for resume, saves to history on completion, and schedules turn reminder notifications. A minimap of players shows the four color-coded avatars at the top of the screen with their token counts.

TypeScript
// src/screens/GameScreen.tsx — Main gameplay screen wiring all components together
import React, { useEffect, useMemo, useCallback } from 'react';
import { View, StyleSheet, Text, Alert } from 'react-native';
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import Board from '../components/Board';
import Dice from '../components/Dice';
import PlayerPanel from '../components/PlayerPanel';
import { useGameSocket } from '../hooks/useGameSocket';
import { useGameState } from '../hooks/useGameState';
import { useOfflineStorage } from '../hooks/useOfflineStorage';
import { useNotifications } from '../hooks/useNotifications';
import { RootStackParamList, GamePhase, PlayerColor } from '../types/game';
import { PLAYER_COLORS } from '../constants/boardConfig';

type Props = NativeStackScreenProps<RootStackParamList, 'Game'>;

export default function GameScreen({ route, navigation }: Props) {
  const { gameId, playerId, playerName } = route.params;
  const { saveCurrentGame, saveGameToHistory } = useOfflineStorage();
  const { scheduleTurnReminder, cancelAllScheduled } = useNotifications(playerId);

  const {
    gameState, connectionStatus, sendRoll, sendMove, subscribe,
  } = useGameSocket({ gameId, playerId });

  const localState = useGameState(gameState?.players ?? []);

  // Sync server state into local state machine
  useEffect(() => {
    if (gameState) {
      localState.setServerState(gameState);
      saveCurrentGame(gameState);
      if (gameState.phase === GamePhase.GAME_OVER && gameState.winner !== null) {
        cancelAllScheduled();
        navigation.replace('Result', {
          gameId,
          winner: gameState.winner,
          players: gameState.players.map(p => p.displayName),
          moveCount: gameState.moveCount,
        });
        saveGameToHistory({
          gameId, finishedAt: new Date().toISOString(),
          players: gameState.players.map(p => p.displayName),
          winner: gameState.winner, moveCount: gameState.moveCount, state: gameState,
        });
      }
    }
  }, [gameState, localState, saveCurrentGame, saveGameToHistory, cancelAllScheduled, navigation, gameId]);

  const handleRoll = useCallback(() => {
    sendRoll();
  }, [sendRoll]);

  const handleTokenPress = useCallback((tokenIndex: number) => {
    if localState.state.phase === GamePhase.MOVING {
      sendMove(tokenIndex);
    }
  }, [localState.state.phase, sendMove]);

  const currentPlayerId = gameState?.players[gameState.currentPlayerIndex]?.playerId ?? '';
  const isMyTurn = currentPlayerId === playerId;
  const canRoll = isMyTurn && localState.state.phase === GamePhase.ROLLING;
  const movableTokenIndices = localState.getMovableTokenIndices();

  if (!gameState) {
    return (
      <View style={styles.center}>
        <Text>Connecting to game...</Text>
        <Text style={styles.status}>{connectionStatus}</Text>
      </View>
    );
  }

  return (
    <View style={styles.container}>
      <View style={styles.playerBar}>
        {gameState.players.map((player, idx) => (
          <View key={player.playerId} style={[
            styles.playerChip,
            { backgroundColor: PLAYER_COLORS[player.color] + '33' },
            idx === gameState.currentPlayerIndex && styles.activePlayerChip,
          ]}>
            <View style={[styles.colorDot, { backgroundColor: PLAYER_COLORS[player.color] }]} />
            <Text style={styles.playerName} numberOfLines={1}>{player.displayName}</Text>
            <Text style={styles.finishedCount}>{\`\${player.finishedTokens}/4\`}</Text>
          </View>
        ))}
      </View>

      <Board
        gameState={localState.state}
        currentPlayerId={playerId}
        highlightedTokenIndices={[]}
        onTokenPress={handleTokenPress}
        onCellPress={() => {}}
        movableTokenIndices={movableTokenIndices}
      />

      <View style={styles.controls}>
        <Dice
          value={localState.state.diceValue}
          onRoll={handleRoll}
          disabled={!canRoll}
          size={72}
        />
        <Text style={[styles.turnIndicator, { color: isMyTurn ? '#FF6B00' : '#666' }]}>
          {isMyTurn
            ? (localState.state.phase === GamePhase.ROLLING ? '🎲 Your turn — Roll the dice!' : 'Select a token to move')
            : `Waiting for \${gameState.players[gameState.currentPlayerIndex]?.displayName ?? 'opponent'}`
          }
        </Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#F0F0F0', padding: 8 },
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  status: { marginTop: 8, color: '#666', fontSize: 14 },
  playerBar: { flexDirection: 'row', justifyContent: 'space-around', marginBottom: 12 },
  playerChip: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 16 },
  activePlayerChip: { borderWidth: 2, borderColor: '#FF6B00' },
  colorDot: { width: 10, height: 10, borderRadius: 5, marginRight: 6 },
  playerName: { fontSize: 12, fontWeight: '600', maxWidth: 60 },
  finishedCount: { marginLeft: 4, fontSize: 12, color: '#666' },
  controls: { alignItems: 'center', marginTop: 12, paddingBottom: 20 },
  turnIndicator: { marginTop: 12, fontSize: 16, fontWeight: '600', textAlign: 'center' },
});

Step 11 — Building and Testing

With Expo, you have three paths for building: the Expo Go app for instant development testing, a custom development build with npx expo prebuild for native module support, and EAS Build for production App Store and Play Store binaries. For Firebase Cloud Messaging to work, you must use npx expo prebuild to generate the native iOS and Android directories, then configure Firebase credentials.

Bash
# Development: Run in Expo Go (fastest for iteration)
npx expo start

# Development with native modules (Firebase, haptics, etc.)
npx expo prebuild  # Generates ios/ and android/ directories
npx expo run:ios    # Runs on iOS simulator with custom dev client
npx expo run:android  # Runs on Android emulator with custom dev client

# Production: EAS Build (no macOS required for iOS)
npm install -g eas-cli
eas login
eas build --platform ios --profile production
eas build --platform android --profile production

# Local Android APK build (no EAS account needed)
eas build --platform android --local --profile production

The LudoKingAPI backend at https://api.ludokingapi.site must have your Expo push token registered for push notifications to reach users. See the Realtime API docs for the push notification endpoint specification. For game development fundamentals including board geometry and rule implementation, read the Ludo Game Tutorial. For integrating with the Android platform, visit the Ludo Android API page.

Frequently Asked Questions

React Native Reanimated uses Babel to transform worklet functions at compile time, replacing standard JavaScript functions with animated worklets that run on the UI thread. Without the react-native-reanimated/plugin in your Babel config, animations will fall back to the JS-thread-based Animated API and suffer from dropped frames. Add plugins: ['react-native-reanimated/plugin'] to babel.config.js and clear the Metro cache with npx expo start --clear after adding it, otherwise the plugin will not be applied to cached modules.
When the app loses connectivity, the useGameSocket hook triggers automatic reconnection with exponential backoff. Meanwhile, the last known game state is already persisted in AsyncStorage via saveCurrentGame, which is called on every state update. When reconnection succeeds, the WebSocket server sends the authoritative state-update message, and the local state is overwritten with the server truth. This means a player who loses connectivity briefly will see a brief loading state and then snap to the correct game position — no manual sync is required. The game history feature stores completed games so players can review past matches offline indefinitely.
React Native Skia is an excellent choice for complex board rendering and provides GPU-accelerated drawing, but it is incompatible with Expo's managed workflow — it requires native module access and npx expo prebuild to function. Skia is ideal if you need high-fidelity token animations with physics, blur effects on safe zones, or a custom-drawn board with gradients. For the View-based approach in this tutorial, react-native-reanimated handles smooth token movement with spring physics on the UI thread, achieving comparable visual quality without native dependencies. If you prefer Skia, use npx react-native init (bare workflow) and install @shopify/react-native-skia — the board layout code will need to be adapted to use Skia's Canvas and Circle components instead of View elements.
For Expo managed workflow, Firebase Cloud Messaging requires running npx expo prebuild to generate native directories, then manually adding the google-services.json (Android) and GoogleService-Info.plist (iOS) to the project. On iOS, you must also enable "Push Notifications" in your Apple Developer account and upload the APNs certificate or key to Firebase. The expo-notifications library wraps FCM on Android and APNs on iOS behind a unified API, so your notification handling code in useNotifications works identically on both platforms. For development without Firebase, use npx expo start --send-to to send test notifications to a specific device from the Expo dashboard.
AsyncStorage has a per-key size limit of approximately 6MB on iOS and 10MB on Android. With 20 saved games stored under a single key, each with a full GameState (16 tokens, 4 players, move history), the total serialized JSON stays well within these limits. However, for production apps with replay features or detailed move analytics, migrate to @react-native-async-storage/async-storage with separate keys per game (@ludo/game_<id>) and a separate index key for the game list. This avoids loading all history into memory at once and allows selective deletion of old games.
Yes. The useGameSocket hook exposes the same sendRoll and sendMove functions used by human players. Create a bot service that implements a decision algorithm — a simple rule-based bot prioritises capturing opponents, landing on star squares, advancing tokens closest to home, and always entering tokens when a 6 is rolled. A more advanced bot uses Monte Carlo Tree Search (MCTS) to evaluate move outcomes. Inject the bot as a pseudo-player: when it is the bot's turn, trigger sendRoll(), wait for the state-update, then compute and send sendMove(tokenIndex) after a configurable delay (1–3 seconds) to simulate thinking time. The bot integrates transparently with the existing WebSocket infrastructure.
For the move history log displayed in the game screen, use FlashList from @shopify/flash-list instead of the standard FlatList. FlashList is significantly faster because it calculates item sizes at initialization rather than lazily, and it recycles cells more aggressively. Memoize each log entry component with React.memo and use stable callback references (useCallback) for event handlers. Keep the move log as a simple array of plain objects rather than nested state — avoid storing move history inside the main game state object to prevent unnecessary re-renders of the entire board when only the log changes.

Ready to Build Your Ludo Mobile Game?

Need a backend API, multiplayer infrastructure, or custom Ludo game development? The LudoKingAPI team can help you ship faster with real-time APIs, push notifications, and matchmaking.