Ludo React Native Tutorial — Full App with Expo, WebSocket, Board Rendering, Dice, Firebase Notifications & Offline Storage
Build a complete, production-ready Ludo multiplayer mobile game in React Native. This tutorial covers Expo project setup, React Navigation with multiple screens, a WebSocket hook for real-time multiplayer, a pixel-accurate board renderer using native Views, an animated dice component, Firebase Cloud Messaging for push notifications, and AsyncStorage for offline state persistence. Every code block is complete and production-ready — no placeholders.
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.
# 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:
// 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.
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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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.
# 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/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.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.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.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.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.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.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.