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 (1)
Nice writeup, the heartbeat + 15s timeout detection is a smart choice for turn-based - acceptable
latency budget plus you avoid wedged-game states where one player rage-quits and the other waits
forever.
Two things worth flagging for anyone considering this stack for their own game:
The 380ms onSnapshot latency you mention is fine for turn-based but starts feeling laggy past
200ms for any real-time mechanic (movement, instant action games). Firestore transactions also
serialize per-document so your "atomic stats update for both players" pattern doesn't scale
linearly past ~100-200 concurrent matches because all matches end up writing the same player
documents. Worth knowing if you ever expand the stack.
For the username cooldown and avatar moderation pieces, those are exactly the kind of features
that alternatives like Supercraft GSB, Nakama, or PlayFab
handle as built-ins (rate limits + content moderation hooks) so you don't have to write the
cooldown logic yourself. Not saying swap Firebase, just calling out that the off-game backend
layer can be its own service if your custom logic gets heavier later.
Cool that you went 17 languages right out of the gate - i18n as an afterthought is way harder to
retrofit.