Building a Real-time Multiplayer Game with React + Firebase: MMA XOX Case Study
I've spent the last few months building MMA XOX, a real-time multiplayer Tic Tac Toe game with ranked matchmaking. This is my complete breakdown of the architecture, challenges, and solutions.
🎮 Project Overview
MMA XOX is a multiplayer game where:
- Players compete in real-time Tic Tac Toe matches
- Ranked system tracks points and tiers (Bronze → Silver → Gold → Diamond)
- 17 language support from day one
- Global leaderboards show top players
- Users can create rooms, join public games, or find random opponents
📊 Stats: 25 lines of code, 17 playable languages, real-time sync under 500ms
🎨 User Interface & Game Modes
Before diving into the architecture, let's understand what users see and how they interact with the game.
Menu - Game Mode Selection
The main hub where players choose their game mode:
Technical Implementation:
// Menu.tsx
const [showCreateFields, setShowCreateFields] = useState(false);
const [showJoinFields, setShowJoinFields] = useState(false);
const [showRandomFields, setShowRandomFields] = useState(false);
const { languageDropdown, setLanguageDropdown } = useState(false);
// Language & Theme toggles
const { t, i18n } = useTranslation();
const { theme, toggleTheme } = useContext(ThemeContext);
Same Screen - Local Multiplayer
Two players on the same device, taking turns on one screen.
Game Rules:
- 6 fighters displayed at top (3x2 grid)
- Players take turns selecting fighters
- Each fighter can only be claimed once
- First to claim 3 fighters in a row wins
- Wrong choice? You pass your turn!
Room - Online Multiplayer
Real-time game where two players join via room code or random matching.
Room State Flow:
// Room.tsx - State variables
const [gameState, setGameState] = useState({
gameStarted: false,
turn: "red", // Whose turn?
fighter00: { url, text, bg },
fighter01: { url, text, bg },
// ... 7 more fighters
winner: null, // null, "red", "blue", or "draw"
isRankedRoom: false,
timerLength: 180, // seconds
lastActivityAt: timestamp,
});
const [role, setRole] = useState("host" | "guest");
const [playerName, setPlayerName] = useState("Player1");
const [guest, setGuest] = useState(null); // Who's the guest?
Real-time Updates:
// Subscribe to room changes
useEffect(() => {
const roomRef = doc(db, "rooms", roomId);
const unsubscribe = onSnapshot(roomRef, (snapshot) => {
const roomData = snapshot.data();
setGameState(roomData);
setGuest(roomData.guest?.now);
setTurn(roomData.turn);
// If game started, load all 9 fighters
if (roomData.gameStarted) {
setFighter00(roomData.fighter00);
setFighter01(roomData.fighter01);
// ... etc
}
});
return () => unsubscribe();
}, [roomId]);
World Ranking - Global Leaderboard
See top players worldwide, sorted by points.
Technical Details:
// WorldRanking.tsx
type Row = {
id: string;
email: string;
username: string;
points: number;
wins: number;
losses: number;
draws: number;
totalGames: number;
winRate: number;
avatarUrl?: string;
};
const PAGE_SIZE = 25; // 25 players per page
// Fetch leaderboard with pagination
const fetchLeaderboard = async (pageNum: number) => {
const q = query(
collection(db, "users"),
orderBy("stats.points", "desc"),
limit(PAGE_SIZE)
);
const snapshot = await getDocs(q);
const players = snapshot.docs.map((doc) => ({
id: doc.id,
...doc.data(),
}));
setLeaderboardData(players);
};
// Optimize Google avatar URLs
const getHighQualityAvatarUrl = (url: string | undefined): string => {
if (!url) return defaultAvatar;
// Google uses =s96-c, change to =s300-c for better quality
return url.replace(/=s\d+-c$/, "=s300-c");
};
Profile - Player Statistics & Achievements
Each player has a detailed profile with stats, titles, and achievements.
Data Structure:
// Profile.tsx
interface UserProfile {
username: string;
email: string;
avatarUrl: string;
activeTitle: string;
unlockedTitles: string[];
achievements: {
firstWin?: boolean;
tenWins?: boolean;
flawlessVictory?: boolean;
// ... more achievements
};
stats: {
points: number;
totalGames: number;
wins: number;
losses: number;
draws: number;
winRate: number;
};
createdAt: string;
lastUsernameChangeAt: string; // Can't change for 2 days
}
const achievementsList = {
firstWin: {
name: "profile.firstBlood",
description: "profile.firstBloodDesc",
icon: "🥊",
},
tenWins: {
name: "profile.arenaMaster",
description: "profile.arenamasterDesc",
icon: "🏆",
},
flawlessVictory: {
name: "profile.flawlessVictory",
description: "profile.flawlessVictoryDesc",
icon: "👑",
},
};
Friends - Social Network
Track friends and recent opponents, see their stats.
Features:
- Online status indicator
- Head-to-head record (W-L)
- One-click "Play Again"
- Spectate active games
- Friend request system
🛠️ Tech Stack
Frontend:
- React 18 + TypeScript
- Vite (dev server & build)
- React Router v6 (client-side routing)
- Tailwind CSS (styling)
- i18next (17 languages)
- React Toastify (notifications)
- react-ga4 (analytics)
Backend:
- Firebase Authentication (email/password, Google, Twitter)
- Firestore (real-time database)
- Firebase Storage (avatar uploads)
- Cloud Functions (game logic validation)
Real-time Communication:
- Firestore onSnapshot listeners
- Server timestamp for coordination
- 5s heartbeat + 15s timeout detection
Hosting:
- Vercel (frontend)
- Firebase (backend)
🏗️ Architecture Deep Dive
1. Authentication System
// AuthContext.tsx - Minimal but complete
const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
useEffect(() => {
const unsubscribe = auth.onAuthStateChanged((user) => {
setCurrentUser(user);
});
return unsubscribe;
}, []);
const signInWithGoogle = async () => {
const provider = new GoogleAuthProvider();
const result = await signInWithPopup(auth, provider);
// Create/update user profile on first login
const userRef = doc(db, "users", result.user.email!);
const userDoc = await getDoc(userRef);
if (!userDoc.exists()) {
await setDoc(userRef, {
username: result.user.email!.split("@")[0],
email: result.user.email,
avatarUrl: result.user.photoURL || defaultAvatar,
stats: {
points: 100,
totalGames: 0,
wins: 0,
losses: 0,
draws: 0,
winRate: 0,
},
activeTitle: "Arena Rookie",
unlockedTitles: ["Arena Rookie"],
achievements: {},
createdAt: new Date().toISOString(),
});
}
};
return (
<AuthContext.Provider value={{ currentUser, signInWithGoogle, ... }}>
{children}
</AuthContext.Provider>
);
};
Key Learning: First login creates complete user profile. Google photo becomes avatar.
2. Room Management & Real-time Sync
// Room.tsx - Game state subscription
useEffect(() => {
if (!roomId) return;
const roomRef = doc(db, "rooms", roomId);
const unsubscribe = onSnapshot(roomRef, (snapshot) => {
if (!snapshot.exists()) {
navigate("/");
return;
}
const roomData = snapshot.data();
// Update all game state from Firestore
setGameState(roomData);
setGuest(roomData.guest?.now);
setTurn(roomData.turn);
// Restore fighter selections if game running
if (roomData.gameStarted) {
setFighter00(roomData.fighter00);
setFighter01(roomData.fighter01);
// ... 7 more fighters
setGameStarted(true);
}
}, (error) => {
console.error("Room listener error:", error);
toast.error("Connection lost");
});
return () => unsubscribe();
}, [roomId, role]);
Real-time Flow:
- Host creates room → Firestore document created
- Guest joins → guest.now field updated
- Both players make moves → fighter##.bg color updated
- All updates sync instantly via onSnapshot
3. Ranked Matchmaking
// Menu.tsx - Find ranked opponent
const handleRankedMatch = async () => {
const playerName = getPlayerName();
try {
// Find waiting ranked rooms with empty guest slot
const roomsRef = collection(db, "rooms");
const q = query(
roomsRef,
where("guest.now", "==", null),
where("isRankedRoom", "==", true),
);
const querySnapshot = await getDocs(q);
if (querySnapshot.empty) {
setShowRankedNoRooms(true);
return;
}
const availableRooms = querySnapshot.docs.map(doc => ({
id: doc.id,
...doc.data()
}));
const randomRoom = availableRooms[
Math.floor(Math.random() * availableRooms.length)
];
await updateDoc(doc(db, "rooms", randomRoom.id), {
guest: { prev: null, now: playerName },
guestEmail: currentUser?.email,
guestJoinMethod: "random",
});
navigate(`/room/${randomRoom.id}`, {
state: { role: "guest", name: playerName, isRanked: true }
});
} catch (error) {
toast.error("Finding opponent failed");
}
};
4. Game Flow: Host Setup → Start Game
// Room.tsx - Host initializes game
const startGame = async (customTimerLength?: string) => {
if (!roomId) return;
const roomRef = doc(db, "rooms", roomId);
await updateDoc(roomRef, {
gameStarted: true,
difficulty: difficulty,
timerLength: customTimerLength || timerLength,
originalTimerLength: customTimerLength || timerLength,
turn: "red",
lastActivityAt: serverTimestamp(),
expireAt: Timestamp.fromMillis(Date.now() + 1 * 60 * 60 * 1000),
});
setGameStarted(true);
};
5. Player Move & Turn Management
// Room.tsx - Player selects fighter
const updateBox = async (selectedFighter: Fighter) => {
// Validate turn
if (role === "host" && gameState.turn === "blue") {
toast.error("Opponent's turn!");
return;
}
const positionKey = getPositionForBox(selected);
const validFightersAtPosition = positionsFighters[positionKey];
if (!validFightersAtPosition.includes(selectedFighter)) {
playSfx(wrongSound);
// Wrong choice = pass turn without progress
await updateDoc(doc(db, "rooms", roomId), {
turn: gameState.turn === "red" ? "blue" : "red",
timerLength: gameState.timerLength,
});
return;
}
// Correct choice!
playSfx(correctSound);
const bgColor = gameState.turn === "red"
? "from-red-800 to-red-900"
: "from-blue-800 to-blue-900";
await updateDoc(doc(db, "rooms", roomId), {
[selected]: {
url: selectedFighter.Picture,
text: selectedFighter.Fighter,
bg: bgColor,
fighterId: selectedFighter.Id,
},
turn: gameState.turn === "red" ? "blue" : "red",
lastActivityAt: serverTimestamp(),
});
};
6. Ranked Stats Update (Transaction)
// Room.tsx - After game ends
const updatePlayerStats = async (winner: "red" | "blue" | "draw") => {
if (!gameState?.isRankedRoom) return;
const hostRef = doc(db, "users", gameState.hostEmail);
const guestRef = doc(db, "users", gameState.guestEmail);
const roomRef = doc(db, "rooms", roomId);
try {
await runTransaction(db, async (transaction) => {
const hostDoc = await transaction.get(hostRef);
const guestDoc = await transaction.get(guestRef);
const hostStats = hostDoc.data()?.stats || {};
const guestStats = guestDoc.data()?.stats || {};
const hostWon = winner === "red";
const guestWon = winner === "blue";
const isDraw = winner === "draw";
const hostChange = hostWon ? 15 : isDraw ? 2 : -5;
const guestChange = guestWon ? 15 : isDraw ? 2 : -5;
// Atomic update
transaction.update(hostRef, {
"stats.points": (hostStats.points || 0) + hostChange,
"stats.totalGames": (hostStats.totalGames || 0) + 1,
"stats.wins": (hostStats.wins || 0) + (hostWon ? 1 : 0),
"stats.losses": (hostStats.losses || 0) + (guestWon ? 1 : 0),
"stats.draws": (hostStats.draws || 0) + (isDraw ? 1 : 0),
});
transaction.update(guestRef, {
"stats.points": (guestStats.points || 0) + guestChange,
"stats.totalGames": (guestStats.totalGames || 0) + 1,
"stats.wins": (guestStats.wins || 0) + (guestWon ? 1 : 0),
"stats.losses": (guestStats.losses || 0) + (hostWon ? 1 : 0),
"stats.draws": (guestStats.draws || 0) + (isDraw ? 1 : 0),
});
transaction.update(roomRef, {
statsUpdated: true,
});
});
toast.success("Ranked match completed!");
} catch (error) {
console.error("Stats update failed:", error);
}
};
Why Transaction? Ensures both users' stats update atomically. No partial updates!
7. Heartbeat & Disconnect Detection
// Room.tsx - Keep alive ping
useEffect(() => {
if (!roomId || !gameState?.gameStarted || gameState?.winner) {
return;
}
const heartbeatInterval = setInterval(async () => {
const roomRef = doc(db, "rooms", roomId);
if (role === "host") {
await updateDoc(roomRef, {
hostLastActive: serverTimestamp(),
});
} else {
await updateDoc(roomRef, {
guestLastActive: serverTimestamp(),
});
}
}, 5000);
return () => clearInterval(heartbeatInterval);
}, [roomId, gameState?.gameStarted, role]);
// Timeout checker
useEffect(() => {
if (!roomId || !gameState?.gameStarted) return;
const timeoutCheckInterval = setInterval(async () => {
const roomSnap = await getDoc(doc(db, "rooms", roomId));
const data = roomSnap.data();
const now = Date.now();
const TIMEOUT_MS = 15000;
if (role === "guest") {
const hostLastActive = data.hostLastActive?.toMillis() || 0;
if (now - hostLastActive > TIMEOUT_MS) {
setHostForfeitModal(true);
toast.warning("Host disconnected");
}
}
}, 15000);
return () => clearInterval(timeoutCheckInterval);
}, [roomId, gameState?.gameStarted, role]);
💡 Key Learnings
1. Profile System with Username Cooldown
// Login.tsx
const newUserProfile = {
email: result.user.email,
username: desiredUsername.toLowerCase(),
lastUsernameChangeAt: new Date().toISOString(),
stats: { points: 100, wins: 0, losses: 0, ... },
avatarUrl: defaultAvatar,
activeTitle: "Arena Rookie",
unlockedTitles: ["Arena Rookie"],
createdAt: new Date().toISOString(),
};
await setDoc(doc(db, "users", userEmail), newUserProfile);
2. Multi-language i18next Setup
// i18n/config.ts
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
i18n.use(LanguageDetector).init({
resources: {
en: { translation: require("./locales/en.json") },
tr: { translation: require("./locales/tr.json") },
pt: { translation: require("./locales/pt.json") },
// ... 14 more languages
},
fallbackLng: "en",
detection: {
order: ["localStorage", "navigator"],
},
});
Result: 17 languages, user preferences saved in localStorage
3. Avatar Handling (Base64 Storage)
// Profile.tsx
const handleAvatarUpload = async () => {
const reader = new FileReader();
reader.onload = async () => {
const base64String = reader.result as string;
await updateDoc(doc(db, "users", currentUser.email), {
avatarUrl: base64String,
});
};
reader.readAsDataURL(avatarFile);
};
// Use it with quality optimization
const getHighQualityAvatarUrl = (url: string) => {
if (!url) return defaultAvatar;
return url.replace(/=s\d+-c$/, "=s300-c");
};
🚀 Performance Optimizations
- Firestore Indexing - Ranked queries need indexes:
- Collection: users
- Fields: stats.points (Descending), stats.wins, stats.totalGames
- Room TTL - Auto-delete rooms after 1 hour
- Lazy Loading - Code splitting with React.lazy
- Avatar Compression - Max 1MB per image
🔧 Common Gotchas & Solutions
| Issue | Solution |
|---|---|
| Double stats update | Use statsUpdated flag |
| Stale listeners | Always cleanup with return () => unsubscribe()
|
| Avatar too large | Compress before Base64 |
| Username spam | 2-day cooldown between changes |
📊 Results
After implementation:
- ⚡ Real-time sync: ~380ms average latency
- 🎮 Supports 100+ concurrent games
- 📱 Mobile-first responsive design
- 🌍 Works in 17 languages
- 🔐 Role-based access (host/guest/ranked)
🔗 Resources
Have questions about multiplayer game architecture or Firebase? Drop them below! 🚀
#react #firebase #gamedev #multiplayer #typescript






Top comments (0)