DEV Community

Cover image for Building a Real-time Multiplayer Game with React + Firebase - MMA XOX Case Study
Mert Gökmen
Mert Gökmen

Posted on • Originally published at dev.to

Building a Real-time Multiplayer Game with React + Firebase - MMA XOX Case Study

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:

Menu

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);
Enter fullscreen mode Exit fullscreen mode

Same Screen - Local Multiplayer

Two players on the same device, taking turns on one screen.

Same Screen Game

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.

Ranked Match

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?
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

World Ranking - Global Leaderboard

See top players worldwide, sorted by points.

World Rankings

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");
};
Enter fullscreen mode Exit fullscreen mode

Profile - Player Statistics & Achievements

Each player has a detailed profile with stats, titles, and achievements.

Profile Page

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: "👑",
  },
};
Enter fullscreen mode Exit fullscreen mode

Friends - Social Network

Track friends and recent opponents, see their stats.

Friend

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)
Enter fullscreen mode Exit fullscreen mode

🏗️ 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

Real-time Flow:

  1. Host creates room → Firestore document created
  2. Guest joins → guest.now field updated
  3. Both players make moves → fighter##.bg color updated
  4. 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");
  }
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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(),
  });
};
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

💡 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);
Enter fullscreen mode Exit fullscreen mode

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"],
  },
});
Enter fullscreen mode Exit fullscreen mode

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");
};
Enter fullscreen mode Exit fullscreen mode

🚀 Performance Optimizations

  1. Firestore Indexing - Ranked queries need indexes:
   - Collection: users
   - Fields: stats.points (Descending), stats.wins, stats.totalGames
Enter fullscreen mode Exit fullscreen mode
  1. Room TTL - Auto-delete rooms after 1 hour
  2. Lazy Loading - Code splitting with React.lazy
  3. 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)