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 (1)

Collapse
 
xytras profile image
xytras

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.