November 17, 202514 min

Multiplayer Tic‑Tac‑Toe with Matchmaking via Nuxt 4 + TypeScript + Firebase

Build a real‑time, two‑player Tic‑Tac‑Toe with one‑click matchmaking using Nuxt 4, TypeScript, and Firebase (Firestore + Auth).

Multiplayer Tic‑Tac‑Toe with Matchmaking via Nuxt 4 + TypeScript + Firebase

nuxt-firebase-tictactoe

Ship a real‑time multiplayer Tic‑Tac‑Toe with automatic matchmaking in under an hour. We’ll use:

  • Nuxt 4 + TypeScript
  • Firebase Web SDK v10 (modular) with Auth (anonymous) and Firestore
  • Firestore client‑side transaction for race‑free matchmaking
  • Firestore persistent local cache with multi‑tab support (initializeFirestore + persistentLocalCache)

Tags: Nuxt 4, TypeScript, Firebase, Firestore, Auth, Realtime

Time to read: 14 min

What we’re building

  • A Nuxt 4 app that signs users in anonymously.
  • Click “Find Match” to auto‑pair with another waiting player, or create a new game if none exists.
  • Real‑time gameplay synced via Firestore.
  • Minimal, safe rules that allow only legal moves and race‑free join.

Prerequisites

  • Node.js 18+ installed
  • A Firebase project (Firestore + Authentication enabled). Authentication: enable Anonymous sign‑in.
  • Basic familiarity with Nuxt and TypeScript

1) Create a fresh Nuxt 4 app

npx nuxi@latest init nuxt4-ttt
cd nuxt4-ttt
npm install

Install Firebase SDK:

npm i firebase

Run the dev server to verify the app boots:

npm run dev

Open http://localhost:3000

2) Configure runtime env for Firebase

Add these to your .env (use your Firebase project values):

NUXT_PUBLIC_FIREBASE_API_KEY=YOUR_API_KEY
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=YOUR_PROJECT.firebaseapp.com
NUXT_PUBLIC_FIREBASE_PROJECT_ID=YOUR_PROJECT_ID
NUXT_PUBLIC_FIREBASE_APP_ID=YOUR_APP_ID

Update nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  typescript: { strict: true },
  runtimeConfig: {
    public: {
      firebase: {
        apiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY,
        authDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
        projectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID,
        appId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID
      }
    }
  }
});

3) Initialize Firebase (client‑only) with the new persistent local cache

We’ll use initializeFirestore with persistentLocalCache and persistentMultipleTabManager for modern, robust offline support.

// composables/useFirebase.ts
import type { FirebaseApp, FirebaseOptions } from 'firebase/app';
import { initializeApp, getApps } from 'firebase/app';
import type { Firestore } from 'firebase/firestore';
import {
  initializeFirestore,
  persistentLocalCache,
  persistentMultipleTabManager
} from 'firebase/firestore';
import type { Auth } from 'firebase/auth';
import { getAuth } from 'firebase/auth';

let _app: FirebaseApp | null = null;
let _db: Firestore | null = null;
let _auth: Auth | null = null;

export function useFirebase() {
  if (process.server) {
    throw new Error('useFirebase must be called on the client');
  }

  if (!_app) {
    const cfg = useRuntimeConfig().public.firebase as FirebaseOptions;
    if (!getApps().length) {
      _app = initializeApp(cfg);
    } else {
      _app = getApps()[0]!;
    }

    _db = initializeFirestore(_app, {
      localCache: persistentLocalCache({
        tabManager: persistentMultipleTabManager()
      })
    });

    _auth = getAuth(_app);
  }

  return {
    app: _app!,
    db: _db!,
    auth: _auth!
  };
}

4) Auth composable (anonymous sign‑in)

// composables/useAuth.ts
import type { User } from 'firebase/auth';
import { onAuthStateChanged, signInAnonymously } from 'firebase/auth';

export function useAuth() {
  const { auth } = useFirebase();
  const user = useState<User | null>('auth:user', () => null);
  const ready = useState<boolean>('auth:ready', () => false);

  onMounted(() => {
    const stop = onAuthStateChanged(auth, (u) => {
      user.value = u;
      ready.value = true;
    });
    onUnmounted(() => stop());
  });

  async function ensureAnon() {
    if (!auth.currentUser) {
      await signInAnonymously(auth);
    }
    return auth.currentUser!;
  }

  return { user, ready, ensureAnon };
}

5) Types and helpers for Tic‑Tac‑Toe

// types/tictactoe.ts
export type PlayerSymbol = 'X' | 'O';
export type CellKey = `c${0|1|2|3|4|5|6|7|8}`;
export type CellVal = PlayerSymbol | null;

export type Board = {
  c0: CellVal; c1: CellVal; c2: CellVal;
  c3: CellVal; c4: CellVal; c5: CellVal;
  c6: CellVal; c7: CellVal; c8: CellVal;
};

export interface GameDoc {
  playerX: string;
  playerO: string | null;
  board: Board;
  turn: PlayerSymbol;
  createdAt: unknown;  // serverTimestamp
  updatedAt: unknown;  // serverTimestamp
}
// utils/tictactoe.ts
import type { Board, PlayerSymbol } from '~/types/tictactoe';

const lines: [number, number, number][] = [
  [0,1,2], [3,4,5], [6,7,8], // rows
  [0,3,6], [1,4,7], [2,5,8], // cols
  [0,4,8], [2,4,6]           // diagonals
];

export function boardToArray(b: Board): (PlayerSymbol | null)[] {
  return [b.c0,b.c1,b.c2,b.c3,b.c4,b.c5,b.c6,b.c7,b.c8];
}

export function arrayToBoard(a: (PlayerSymbol | null)[]): Board {
  return {
    c0: a[0] ?? null, c1: a[1] ?? null, c2: a[2] ?? null,
    c3: a[3] ?? null, c4: a[4] ?? null, c5: a[5] ?? null,
    c6: a[6] ?? null, c7: a[7] ?? null, c8: a[8] ?? null
  };
}

export function computeWinner(b: Board): PlayerSymbol | 'draw' | null {
  const a = boardToArray(b);
  for (const [i,j,k] of lines) {
    if (a[i] && a[i] === a[j] && a[j] === a[k]) return a[i]!;
  }
  if (a.every(v => v)) return 'draw';
  return null;
}

6) Matchmaking composable with Firestore transaction

We implement: find the oldest waiting game (playerO == null). If none, create one. Join happens in a transaction to avoid races.

// composables/useMatchmaking.ts
import {
  collection, doc, getDocs, limit, query, runTransaction, serverTimestamp, setDoc, where
} from 'firebase/firestore';
import type { GameDoc } from '~/types/tictactoe';

export async function useMatchmaking() {
  const { db, auth } = useFirebase();

  async function findOrCreateMatch(): Promise<string> {
    const uid = auth.currentUser?.uid;
    if (!uid) throw new Error('Not signed in');

    // 1) Look for a waiting game (playerO == null). No orderBy to avoid composite index requirement.
    const q = query(collection(db, 'games'), where('playerO', '==', null), limit(1));
    const qs = await getDocs(q);

    if (!qs.empty) {
      const ref = qs.docs[0].ref;
      try {
        // 2) Try to join via transaction to avoid race conditions.
        const id = await runTransaction(db, async (tx) => {
          const snap = await tx.get(ref);
          if (!snap.exists()) throw new Error('retry');
          const data = snap.data() as GameDoc;

          if (data.playerO === null && data.playerX !== uid) {
            tx.update(ref, {
              playerO: uid,
              updatedAt: serverTimestamp()
            });
            return ref.id;
          }
          throw new Error('retry');
        });
        if (id) return id;
      } catch {
        // Fallback to create if join failed due to contention
      }
    }

    // 3) Create a new game
    const newRef = doc(collection(db, 'games'));
    const emptyBoard: GameDoc['board'] = {
      c0: null, c1: null, c2: null, c3: null, c4: null, c5: null, c6: null, c7: null, c8: null
    };

    const newGame: Partial<GameDoc> = {
      playerX: uid,
      playerO: null,
      board: emptyBoard,
      turn: 'X',
      createdAt: serverTimestamp(),
      updatedAt: serverTimestamp()
    };

    await setDoc(newRef, newGame);
    return newRef.id;
  }

  return { findOrCreateMatch };
}

7) Game composable to play and sync

// composables/useGame.ts
import { doc, onSnapshot, serverTimestamp, updateDoc } from 'firebase/firestore';
import type { Board, GameDoc, PlayerSymbol } from '~/types/tictactoe';
import { arrayToBoard, boardToArray, computeWinner } from '~/utils/tictactoe';

export function useGame(gameId: string) {
  const { db, auth } = useFirebase();
  const game = useState<GameDoc | null>(() => null);
  const loading = useState<boolean>(() => true);
  const error = useState<string | null>(() => null);

  const myUid = computed(() => auth.currentUser?.uid ?? null);
  const mySymbol = computed<PlayerSymbol | null>(() => {
    if (!game.value || !myUid.value) return null;
    return game.value.playerX === myUid.value ? 'X'
         : game.value.playerO === myUid.value ? 'O'
         : null;
  });

  const winner = computed(() => game.value ? computeWinner(game.value.board) : null);
  const isMyTurn = computed(() =>
    game.value && mySymbol.value ? game.value.turn === mySymbol.value : false
  );

  onMounted(() => {
    const ref = doc(db, 'games', gameId);
    const unsub = onSnapshot(ref, (snap) => {
      if (!snap.exists()) {
        error.value = 'Game not found';
        loading.value = false;
        return;
      }
      game.value = snap.data() as GameDoc;
      loading.value = false;
    }, (e) => {
      error.value = e.message || 'Failed to subscribe';
      loading.value = false;
    });
    onUnmounted(() => unsub());
  });

  async function makeMove(index: number) {
    if (!game.value) return;
    if (!mySymbol.value) return;
    if (!isMyTurn.value) return;
    if (winner.value) return;

    const arr = boardToArray(game.value.board);
    if (arr[index]) return; // already occupied

    arr[index] = mySymbol.value;
    const nextTurn: PlayerSymbol = mySymbol.value === 'X' ? 'O' : 'X';

    const ref = doc(db, 'games', gameId);
    // We update the full board (rules ensure only a single cell changed)
    await updateDoc(ref, {
      board: arrayToBoard(arr) as Board,
      turn: nextTurn,
      updatedAt: serverTimestamp()
    });
  }

  return { game, loading, error, mySymbol, winner, isMyTurn, makeMove };
}

8) Pages and UI

Home page: sign in and trigger matchmaking, then navigate to the game route.

<!-- pages/index.vue -->
<script setup lang="ts">
const { ensureAnon, user, ready } = useAuth();
const { findOrCreateMatch } = await useMatchmaking();

const finding = ref(false);
const err = ref<string | null>(null);

onMounted(async () => {
  if (!ready.value) {
    await ensureAnon();
  }
});

async function onFindMatch() {
  err.value = null;
  try {
    finding.value = true;
    await ensureAnon();
    const id = await findOrCreateMatch();
    await navigateTo(`/game/${id}`);
  } catch (e: any) {
    err.value = e?.message ?? 'Failed to find/create a match';
  } finally {
    finding.value = false;
  }
}
</script>

<template>
  <main class="container">
    <h1>Multiplayer Tic‑Tac‑Toe</h1>
    <p class="sub">Nuxt 4 + Firebase (Auth + Firestore)</p>

    <div class="card">
      <p v-if="!ready">Initializing…</p>
      <template v-else>
        <p class="muted">Signed in: {{ user?.uid?.slice(0, 8) ?? 'anonymous' }}</p>
        <button :disabled="finding" @click="onFindMatch">
          {{ finding ? 'Finding match…' : 'Find Match' }}
        </button>
        <p v-if="err" class="error">{{ err }}</p>
      </template>
    </div>
  </main>
</template>

<style scoped>
.container { max-width: 680px; margin: 3rem auto; padding: 0 1rem; }
h1 { margin: 0 0 .25rem; }
.sub { color: #666; margin: 0 0 1.5rem; }
.card { border: 1px solid #e5e7eb; border-radius: 12px; padding: 1.25rem; }
button {
  background: #00DC82; color: #000; border: none; padding: .75rem 1rem;
  border-radius: 8px; cursor: pointer; font-weight: 600;
}
button[disabled] { opacity: .6; cursor: default; }
.error { color: #b91c1c; margin-top: .75rem; }
.muted { color: #666; }
</style>

Game page: render board, current turn, winner, and handle moves.

<!-- pages/game/[id].vue -->
<script setup lang="ts">
import type { PlayerSymbol } from '~/types/tictactoe';

const route = useRoute();
const id = route.params.id as string;

const { ensureAnon } = useAuth();
await ensureAnon();

const { game, loading, error, mySymbol, winner, isMyTurn, makeMove } = useGame(id);

const cells = computed(() => game.value ? [
  game.value.board.c0, game.value.board.c1, game.value.board.c2,
  game.value.board.c3, game.value.board.c4, game.value.board.c5,
  game.value.board.c6, game.value.board.c7, game.value.board.c8
] : Array(9).fill(null));

function onCellClick(i: number) {
  makeMove(i);
}

const status = computed(() => {
  if (loading.value) return 'Loading…';
  if (error.value) return error.value;
  if (!game.value) return 'Game not found';
  if (winner.value === 'draw') return 'Draw!';
  if (winner.value) return `${winner.value} wins!`;
  if (!mySymbol.value) return 'Spectating';
  return isMyTurn.value ? 'Your turn' : 'Opponent\'s turn';
});

const youAre = computed(() => mySymbol.value ? `You are ${mySymbol.value}` : 'You are spectating');
</script>

<template>
  <main class="container">
    <header class="row">
      <div>
        <h1>Tic‑Tac‑Toe</h1>
        <p class="muted">{{ youAre }}</p>
      </div>
      <nav>
        <NuxtLink to="/">Home</NuxtLink>
      </nav>
    </header>

    <section class="status">{{ status }}</section>

    <section class="board" :class="{ done: winner }">
      <button v-for="(c, i) in cells" :key="i" class="cell"
              :disabled="!!c || !!winner || !isMyTurn"
              @click="onCellClick(i)">
        {{ c ?? '' }}
      </button>
    </section>
  </main>
</template>

<style scoped>
.container { max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
.row { display: flex; align-items: baseline; justify-content: space-between; }
.muted { color: #666; }
.status { margin: 1rem 0 1.25rem; font-weight: 600; }
.board {
  display: grid;
  grid-template-columns: repeat(3, 110px);
  grid-template-rows: repeat(3, 110px);
  gap: 10px;
}
.cell {
  font-size: 2.25rem; font-weight: 700;
  border: 2px solid #111; border-radius: 10px;
  background: #fff; cursor: pointer;
}
.cell:disabled { cursor: default; color: #111; background: #f9f9f9; }
.board.done .cell { opacity: .95; }
nav a { text-decoration: none; font-weight: 600; }
</style>

9) Firestore Security Rules

These rules:

  • Allow reads only to authenticated users.
  • Allow create by X (sets empty board, X turn).
  • Allow join when O is null (no board changes).
  • Allow moves only by the player whose turn it is, flipping the turn, and changing exactly one cell from null to their symbol.
  • Restrict changed keys to just what each operation requires.

Paste into your Firestore Rules:

// firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() {
      return request.auth != null;
    }

    match /games/{gameId} {
      allow read: if signedIn();

      allow create: if signedIn()
        && request.resource.data.keys().hasOnly(['playerX','playerO','board','turn','createdAt','updatedAt'])
        && request.resource.data.playerX == request.auth.uid
        && request.resource.data.playerO == null
        && request.resource.data.turn == 'X'
        && request.resource.data.board.c0 == null
        && request.resource.data.board.c1 == null
        && request.resource.data.board.c2 == null
        && request.resource.data.board.c3 == null
        && request.resource.data.board.c4 == null
        && request.resource.data.board.c5 == null
        && request.resource.data.board.c6 == null
        && request.resource.data.board.c7 == null
        && request.resource.data.board.c8 == null;

      function isTurnX() {
        return resource.data.turn == 'X' && request.auth.uid == resource.data.playerX;
      }
      function isTurnO() {
        return resource.data.turn == 'O' && request.auth.uid == resource.data.playerO;
      }
      function turnFlips() {
        return request.resource.data.turn == (resource.data.turn == 'X' ? 'O' : 'X');
      }

      // Single-cell move cases: exactly one cell changed from null -> current turn symbol.
      function caseC0() {
        return resource.data.board.c0 == null
          && request.resource.data.board.c0 == resource.data.turn
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c7 == resource.data.board.c7
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC1() {
        return resource.data.board.c1 == null
          && request.resource.data.board.c1 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c7 == resource.data.board.c7
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC2() {
        return resource.data.board.c2 == null
          && request.resource.data.board.c2 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c7 == resource.data.board.c7
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC3() {
        return resource.data.board.c3 == null
          && request.resource.data.board.c3 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c7 == resource.data.board.c7
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC4() {
        return resource.data.board.c4 == null
          && request.resource.data.board.c4 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c7 == resource.data.board.c7
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC5() {
        return resource.data.board.c5 == null
          && request.resource.data.board.c5 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c7 == resource.data.board.c7
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC6() {
        return resource.data.board.c6 == null
          && request.resource.data.board.c6 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c7 == resource.data.board.c7
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC7() {
        return resource.data.board.c7 == null
          && request.resource.data.board.c7 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c8 == resource.data.board.c8;
      }
      function caseC8() {
        return resource.data.board.c8 == null
          && request.resource.data.board.c8 == resource.data.turn
          && request.resource.data.board.c0 == resource.data.board.c0
          && request.resource.data.board.c1 == resource.data.board.c1
          && request.resource.data.board.c2 == resource.data.board.c2
          && request.resource.data.board.c3 == resource.data.board.c3
          && request.resource.data.board.c4 == resource.data.board.c4
          && request.resource.data.board.c5 == resource.data.board.c5
          && request.resource.data.board.c6 == resource.data.board.c6
          && request.resource.data.board.c7 == resource.data.board.c7;
      }

      function validMove() {
        return (isTurnX() || isTurnO())
          && turnFlips()
          && request.resource.data.diff(resource.data).changedKeys().hasOnly(['board','turn','updatedAt'])
          && (caseC0() || caseC1() || caseC2() || caseC3() || caseC4() || caseC5() || caseC6() || caseC7() || caseC8());
      }

      function joining() {
        return resource.data.playerO == null
          && request.resource.data.playerO == request.auth.uid
          && request.resource.data.playerO != resource.data.playerX
          && request.resource.data.turn == resource.data.turn
          && request.resource.data.board == resource.data.board
          && request.resource.data.diff(resource.data).changedKeys().hasOnly(['playerO','updatedAt']);
      }

      allow update: if signedIn() && (joining() || validMove());
    }
  }
}

Publish your rules in the Firebase console.

10) Try it out

  • Start the dev server: npm run dev
  • Open two browsers or an incognito window.
  • Click “Find Match” in one, then in the other. They’ll pair up.
  • Click cells to play. You’ll see instantaneous updates.

11) Notes, improvements, and next steps

  • We used Firestore client‑side transactions to implement safe, simple matchmaking without backend code.
  • Firestore’s initializeFirestore + persistentLocalCache + persistentMultipleTabManager give modern offline + multi‑tab behavior.
  • Security rules enforce legal moves and joining. For production:
    • Add a “finished” state and disallow further moves once a winner (you can compute winner client‑side and store a boolean like finished = true, then update rules to block more moves).
    • Add App Check to mitigate abuse.
    • Add a Cloud Function to clean up stale games (or use Firestore TTL with an expiresAt field).
  • UI polish: timers, rematch, chat, and spectating lobby.

Conclusion

You built a real‑time multiplayer Tic‑Tac‑Toe with one‑click matchmaking using Nuxt 4, TypeScript, and Firebase. We leaned on the latest Firebase Web SDK patterns (initializeFirestore persistent local cache) and safe client‑side transactions for contention‑free pairing.

Have fun extending this into your next competitive mini‑game!