November 21, 202512 min

Live Trivia Quiz and Leaderboard using Nuxt 4 + TypeScript + Firebase

Build a real-time trivia game with Nuxt 4, TypeScript, and Firebase using Firestore listeners, Auth (anonymous), App Check with reCAPTCHA Enterprise, and a server-side tally with Cloud Functions v2.

Live Trivia Quiz and Leaderboard using Nuxt 4 + TypeScript + Firebase

nuxt-firebase-trivia

Build a real-time trivia quiz with a live leaderboard using Nuxt 4 + TypeScript + Firebase. We’ll use:

  • Firestore real-time listeners
  • Firebase Auth (Anonymous)
  • App Check with reCAPTCHA Enterprise (modern, production-ready protection)
  • Cloud Functions v2 for secure server-side scoring
  • Firestore’s aggregate count() for live player counts

Tags: Nuxt 4, Firebase, Firestore, Functions v2, App Check

Time to read: 12 min

Prerequisites

  • Node.js 18+ installed
  • A Firebase project (with Firestore + Auth enabled)
  • Basic familiarity with Nuxt 3/4 and TypeScript

What We’ll Build

  • Join screen: users pick a nickname and join a game by code.
  • Host creates a game and launches questions.
  • Players answer in real time; leaderboard updates instantly.
  • Scores are computed securely on the server using Cloud Functions v2 when the host reveals the answer.
  • App Check protects your backend from abuse with reCAPTCHA Enterprise.

1) Create the Nuxt 4 app

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

Run and verify:

npm run dev

Open http://localhost:3000

2) Install Firebase (Web v10+ modular SDK)

npm i firebase
npm i -D @types/node

3) Configure Nuxt runtime config (public Firebase env)

Create a .env file with your Firebase Web app config from the Firebase Console (Project Settings > General > Your apps).

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
NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID=G-XXXXXXX
# Optional but recommended for production
NUXT_PUBLIC_RECAPTCHA_ENTERPRISE_SITE_KEY=YOUR_RECAPTCHA_ENTERPRISE_SITE_KEY

Update nuxt.config.ts:

// nuxt.config.ts
export default defineNuxtConfig({
  future: { typescriptBundlerResolution: true },
  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,
        measurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID,
      },
      recaptchaEnterpriseSiteKey: process.env.NUXT_PUBLIC_RECAPTCHA_ENTERPRISE_SITE_KEY || '',
    },
  },
})

4) Client-only Firebase plugin with App Check and Firestore persistent cache 2.0

Create plugins/firebase.client.ts:

// plugins/firebase.client.ts
import { defineNuxtPlugin } from '#app'
import { initializeApp, getApps, getApp } from 'firebase/app'
import { getAuth, signInAnonymously, onAuthStateChanged } from 'firebase/auth'
import {
  initializeFirestore,
  persistentLocalCache,
  persistentMultipleTabManager,
} from 'firebase/firestore'
import { initializeAppCheck, ReCaptchaEnterpriseProvider } from 'firebase/app-check'

type FirebaseServices = {
  app: ReturnType<typeof initializeApp>
  auth: ReturnType<typeof getAuth>
  db: ReturnType<typeof initializeFirestore>
}

let services: FirebaseServices | null = null

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()
  const publicFirebase = config.public.firebase

  const app = getApps().length
    ? getApp()
    : initializeApp({
        apiKey: publicFirebase.apiKey,
        authDomain: publicFirebase.authDomain,
        projectId: publicFirebase.projectId,
        appId: publicFirebase.appId,
        measurementId: publicFirebase.measurementId,
      })

  // Firestore persistent cache across tabs (modern local cache v2)
  const db = initializeFirestore(app, {
    localCache: persistentLocalCache({
      tabManager: persistentMultipleTabManager(),
    }),
  })

  const auth = getAuth(app)

  // App Check with reCAPTCHA Enterprise (recommended for production)
  const siteKey = config.public.recaptchaEnterpriseSiteKey
  if (siteKey) {
    initializeAppCheck(app, {
      provider: new ReCaptchaEnterpriseProvider(siteKey),
      isTokenAutoRefreshEnabled: true,
    })
  }

  // Ensure anonymous sign-in to secure rules while keeping friction low.
  onAuthStateChanged(auth, (user) => {
    if (!user) {
      void signInAnonymously(auth)
    }
  })

  services = { app, auth, db }
})

export const useFirebase = () => {
  if (!services) throw new Error('Firebase not initialized')
  return services
}

This plugin:

  • Initializes Firebase only on the client.
  • Enables Firestore’s persistent cache for offline-friendly, multi-tab sync.
  • Uses App Check with reCAPTCHA Enterprise (if you provided a site key).
  • Ensures the user is anonymously authenticated to pass security rules.

5) Firestore data model

We’ll structure our data like this:

  • games/{gameId}
    • hostId, createdAt, status
    • state/current
      • phase: 'lobby' | 'question' | 'reveal' | 'ended'
      • questionIndex: number
      • currentQuestionId: string
      • questionStartAt: Timestamp
      • questionDuration: number (seconds)
    • players/{playerId}
      • name, score, joinedAt, present
    • questions/{questionId}
      • text, options: string, correct: string
      • answers/{playerId}
        • answer, at, name
    • tallies/{questionId}
      • doneAt, awarded

6) Secure Firestore rules

In the Firebase Console > Firestore Database > Rules (or firestore.rules):

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /games/{gameId} {
      allow read: if true;

      // Create a game: caller becomes the host.
      allow create: if request.auth != null
        && request.resource.data.hostId == request.auth.uid;

      // Update/delete game only by host
      allow update, delete: if request.auth != null
        && resource.data.hostId == request.auth.uid;

      match /state/{stateId} {
        allow read: if true;
        // Only host can modify the state
        allow write: if request.auth != null
          && get(/databases/$(database)/documents/games/$(gameId)).data.hostId == request.auth.uid;
      }

      match /players/{playerId} {
        allow read: if true;

        // Player creates own doc, score must start at 0
        allow create: if request.auth != null
          && request.auth.uid == playerId
          && request.resource.data.score == 0;

        // Player can update their own presence/name but not score
        allow update: if request.auth != null
          && request.auth.uid == playerId
          && request.resource.data.score == resource.data.score;
      }

      match /questions/{questionId} {
        allow read: if true;

        // Only host can write questions
        allow write: if request.auth != null
          && get(/databases/$(database)/documents/games/$(gameId)).data.hostId == request.auth.uid;

        match /answers/{playerId} {
          // Player and host can read answers
          allow read: if request.auth != null
            && (playerId == request.auth.uid
              || get(/databases/$(database)/documents/games/$(gameId)).data.hostId == request.auth.uid);

          // Player can create exactly one answer doc for this question
          allow create: if request.auth != null
            && request.auth.uid == playerId
            && !exists(/databases/$(database)/documents/games/$(gameId)/questions/$(questionId)/answers/$(playerId));

          // No updates/deletes to answers
          allow update, delete: if false;
        }
      }

      match /tallies/{questionId} {
        // Read-only for clients; written by Cloud Functions
        allow read: if true;
        allow write: if false;
      }
    }
  }
}

Publish rules.

7) Cloud Functions v2: award points on reveal

We tally scores on the server to prevent cheating. When the host switches the game phase from "question" to "reveal", this function:

  • Finds all correct answers
  • Increments corresponding player scores
  • Creates a “tally” doc to keep it idempotent

Initialize Functions:

npm i -g firebase-tools
firebase login
firebase init functions
# Choose TypeScript, Node 20, ESLint as you prefer, and Firestore triggers

Replace functions/src/index.ts with:

// functions/src/index.ts
import { onDocumentUpdated } from 'firebase-functions/v2/firestore'
import { initializeApp } from 'firebase-admin/app'
import { getFirestore, FieldValue } from 'firebase-admin/firestore'

initializeApp()
const db = getFirestore()

export const tallyOnReveal = onDocumentUpdated('games/{gameId}/state/current', async (event) => {
  const before = event.data?.before?.data() as any
  const after = event.data?.after?.data() as any
  if (!after) return

  const wasQuestion = before?.phase === 'question'
  const isReveal = after?.phase === 'reveal'
  const questionId = after?.currentQuestionId as string | undefined
  const gameId = event.params.gameId as string

  if (!wasQuestion || !isReveal || !questionId) return

  // Idempotency: if tally exists, do nothing
  const tallyRef = db.doc(`games/${gameId}/tallies/${questionId}`)
  const existing = await tallyRef.get()
  if (existing.exists) return

  const questionRef = db.doc(`games/${gameId}/questions/${questionId}`)
  const questionSnap = await questionRef.get()
  if (!questionSnap.exists) return
  const correct = questionSnap.get('correct') as string

  const answersRef = db.collection(`games/${gameId}/questions/${questionId}/answers`)
  const correctSnap = await answersRef.where('answer', '==', correct).get()

  let writes = 0
  let batch = db.batch()

  for (const doc of correctSnap.docs) {
    const playerId = doc.id
    const playerRef = db.doc(`games/${gameId}/players/${playerId}`)
    batch.update(playerRef, { score: FieldValue.increment(1) })
    writes++
    if (writes === 400) {
      await batch.commit()
      batch = db.batch()
      writes = 0
    }
  }
  if (writes > 0) {
    await batch.commit()
  }

  await tallyRef.set({ doneAt: FieldValue.serverTimestamp(), awarded: correctSnap.size })
})

Deploy Functions:

cd functions
npm run build
firebase deploy --only functions

8) Sample questions (local seed)

Create assets/questions.json:

[
  {
    "id": "q1",
    "text": "Which planet is known as the Red Planet?",
    "options": ["Earth", "Mars", "Jupiter", "Venus"],
    "correct": "Mars"
  },
  {
    "id": "q2",
    "text": "What is the capital of Japan?",
    "options": ["Seoul", "Beijing", "Tokyo", "Osaka"],
    "correct": "Tokyo"
  },
  {
    "id": "q3",
    "text": "What does HTTP stand for?",
    "options": ["HyperText Transfer Protocol", "High Transfer Text Protocol", "Hyperlink Transfer Process", "Host Transfer Text Protocol"],
    "correct": "HyperText Transfer Protocol"
  }
]

9) Create/Join UI (index page)

  • Host can create a new game and seed questions.
  • Players join an existing game with a nickname and code.

pages/index.vue:

<template>
  <div class="container">
    <h1>Nuxt + Firebase Live Trivia</h1>

    <section class="host">
      <h2>Host a game</h2>
      <button @click="createGame" :disabled="creating">{{ creating ? 'Creating...' : 'Create Game' }}</button>
      <p v-if="hostGameId">Game created: <strong>{{ hostGameId }}</strong></p>
    </section>

    <section class="join">
      <h2>Join a game</h2>
      <form @submit.prevent="joinGame">
        <input v-model.trim="nickname" placeholder="Your nickname" required />
        <input v-model.trim="gameId" placeholder="Game ID (e.g. ABC123)" required />
        <button type="submit">Join</button>
      </form>
      <p v-if="playersCount !== null">Players currently in game: {{ playersCount }}</p>
    </section>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useFirebase } from '~/plugins/firebase.client'
import {
  doc, setDoc, serverTimestamp, collection, addDoc,
  onSnapshot, query, orderBy, limit, getDocs, writeBatch,
  getAggregateFromServer, count as aggCount
} from 'firebase/firestore'

type Question = {
  id: string
  text: string
  options: string[]
  correct: string
}

const { db, auth } = useFirebase()

const nickname = ref('')
const gameId = ref('')
const creating = ref(false)
const hostGameId = ref<string | null>(null)
const playersCount = ref<number | null>(null)

async function createGame() {
  creating.value = true
  try {
    const uid = auth.currentUser?.uid
    if (!uid) throw new Error('Not signed in')

    // Simple random code for gameId
    const newGameId = Math.random().toString(36).slice(2, 8).toUpperCase()
    const gameRef = doc(db, 'games', newGameId)

    await setDoc(gameRef, {
      hostId: uid,
      createdAt: serverTimestamp(),
      status: 'lobby',
    }, { merge: true })

    // Initialize state
    await setDoc(doc(db, 'games', newGameId, 'state', 'current'), {
      phase: 'lobby',
      questionIndex: -1,
      currentQuestionId: null,
      questionStartAt: null,
      questionDuration: 15
    })

    // Seed questions from local json
    const questions: Question[] = (await import('~/assets/questions.json')).default
    const batch = writeBatch(db)
    for (const q of questions) {
      const qRef = doc(db, 'games', newGameId, 'questions', q.id)
      batch.set(qRef, q)
    }
    await batch.commit()

    hostGameId.value = newGameId
  } catch (e) {
    console.error(e)
    alert('Failed to create game')
  } finally {
    creating.value = false
  }
}

async function joinGame() {
  const gid = gameId.value.trim().toUpperCase()
  const name = nickname.value.trim()
  if (!gid || !name) return

  const uid = auth.currentUser?.uid
  if (!uid) {
    alert('Not signed in yet, please try again in a second.')
    return
  }

  const playerRef = doc(db, 'games', gid, 'players', uid)
  await setDoc(playerRef, {
    name,
    score: 0,
    joinedAt: serverTimestamp(),
    present: true,
  }, { merge: true })

  await navigateTo(`/game/${gid}`)
}

let unsubPlayers: (() => void) | null = null

onMounted(() => {
  // Show count of players for the entered game id (when gameId changes)
  // We’ll refresh aggregate on blur/join click to avoid excessive calls.
})

onUnmounted(() => {
  if (unsubPlayers) unsubPlayers()
})

watch(gameId, async (val) => {
  playersCount.value = null
  const gid = val.trim().toUpperCase()
  if (!gid) return
  try {
    // Use Firestore aggregate count() — server-side accurate count
    const qPlayers = collection(db, 'games', gid, 'players')
    const snap = await getAggregateFromServer(qPlayers, { players: aggCount() })
    playersCount.value = snap.data().players as number
  } catch {
    playersCount.value = null
  }
})
</script>

<style scoped>
.container { max-width: 720px; margin: 2rem auto; padding: 1rem; }
section { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
input { margin-right: 0.5rem; padding: 0.5rem; }
button { padding: 0.5rem 1rem; }
</style>

10) Host controls and game flow

The host can:

  • Start the game (go to first question)
  • Reveal the answer (triggers server tally)
  • Next question
  • End game

pages/host/[id].vue:

<template>
  <div class="wrap">
    <h1>Host Controls - {{ gid }}</h1>
    <div v-if="!isHost">You are not the host of this game.</div>

    <div v-else>
      <div class="controls">
        <button @click="startGame" :disabled="busy || (state?.phase !== 'lobby')">Start</button>
        <button @click="reveal" :disabled="busy || (state?.phase !== 'question')">Reveal</button>
        <button @click="next" :disabled="busy || !(state?.phase === 'reveal')">Next</button>
        <button @click="endGame" :disabled="busy || (state?.phase === 'ended')">End</button>
      </div>

      <div class="state">
        <p>Phase: {{ state?.phase }}</p>
        <p>Question index: {{ state?.questionIndex }}</p>
        <p>Current question: {{ state?.currentQuestionId }}</p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useFirebase } from '~/plugins/firebase.client'
import { doc, onSnapshot, getDoc, setDoc, updateDoc, serverTimestamp, collection, getDocs, query, orderBy } from 'firebase/firestore'

const { db, auth } = useFirebase()
const route = useRoute()
const gid = computed(() => String(route.params.id).toUpperCase())
const state = ref<any | null>(null)
const game = ref<any | null>(null)
const busy = ref(false)
const isHost = ref(false)

let unsubState: (() => void) | null = null

onMounted(async () => {
  const gameRef = doc(db, 'games', gid.value)
  const snap = await getDoc(gameRef)
  game.value = snap.data()
  isHost.value = !!auth.currentUser && game.value?.hostId === auth.currentUser?.uid

  const stateRef = doc(db, 'games', gid.value, 'state', 'current')
  unsubState = onSnapshot(stateRef, (s) => {
    state.value = s.data()
  })
})

onUnmounted(() => {
  if (unsubState) unsubState()
})

async function startGame() {
  if (!isHost.value) return
  busy.value = true
  try {
    // Get first question
    const qRef = collection(db, 'games', gid.value, 'questions')
    const qSnap = await getDocs(query(qRef, orderBy('id')))
    const first = qSnap.docs[0]
    if (!first) throw new Error('No questions')
    await setDoc(doc(db, 'games', gid.value, 'state', 'current'), {
      phase: 'question',
      questionIndex: 0,
      currentQuestionId: first.id,
      questionStartAt: serverTimestamp(),
      questionDuration: 15
    }, { merge: true })
  } finally {
    busy.value = false
  }
}

async function reveal() {
  if (!isHost.value) return
  busy.value = true
  try {
    await updateDoc(doc(db, 'games', gid.value, 'state', 'current'), {
      phase: 'reveal'
    })
    // Cloud Function will tally scores
  } finally {
    busy.value = false
  }
}

async function next() {
  if (!isHost.value) return
  busy.value = true
  try {
    // Load all questions sorted by id, find next by index
    const s = state.value
    const qRef = collection(db, 'games', gid.value, 'questions')
    const qSnap = await getDocs(query(qRef, orderBy('id')))
    const docs = qSnap.docs
    const nextIndex = (s?.questionIndex ?? -1) + 1
    if (nextIndex >= docs.length) {
      await endGame()
      return
    }
    const nextQ = docs[nextIndex]
    await setDoc(doc(db, 'games', gid.value, 'state', 'current'), {
      phase: 'question',
      questionIndex: nextIndex,
      currentQuestionId: nextQ.id,
      questionStartAt: serverTimestamp(),
      questionDuration: 15
    }, { merge: true })
  } finally {
    busy.value = false
  }
}

async function endGame() {
  if (!isHost.value) return
  busy.value = true
  try {
    await updateDoc(doc(db, 'games', gid.value, 'state', 'current'), {
      phase: 'ended'
    })
  } finally {
    busy.value = false
  }
}
</script>

<style scoped>
.wrap { max-width: 720px; margin: 2rem auto; padding: 1rem; }
.controls button { margin-right: 0.5rem; padding: 0.5rem 1rem; }
.state { margin-top: 1rem; }
</style>

11) Game page: question, answer, timer, and live leaderboard

components/Leaderboard.vue:

<template>
  <div class="leaderboard">
    <h3>Leaderboard</h3>
    <ol>
      <li v-for="p in players" :key="p.id">
        <strong>{{ p.name }}</strong> – {{ p.score }}
      </li>
    </ol>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useFirebase } from '~/plugins/firebase.client'
import { collection, onSnapshot, orderBy, query, limit } from 'firebase/firestore'

const props = defineProps<{ gameId: string }>()

const { db } = useFirebase()
const players = ref<{ id: string; name: string; score: number }[]>([])

let unsub: (() => void) | null = null

onMounted(() => {
  const ref = collection(db, 'games', props.gameId, 'players')
  const q = query(ref, orderBy('score', 'desc'), limit(10))
  unsub = onSnapshot(q, (snap) => {
    players.value = snap.docs.map(d => ({ id: d.id, ...(d.data() as any) }))
  })
})

onUnmounted(() => { if (unsub) unsub() })
</script>

<style scoped>
.leaderboard { border: 1px solid #e5e7eb; padding: 1rem; border-radius: 8px; }
ol { padding-left: 1.25rem; }
</style>

pages/game/[id].vue:

<template>
  <div class="grid">
    <div class="main">
      <h1>Game: {{ gid }}</h1>
      <p>Phase: <strong>{{ state?.phase }}</strong></p>

      <div v-if="question">
        <h2>{{ question.text }}</h2>

        <div class="meta">
          <span v-if="state?.phase === 'question'">Time left: {{ timeLeft }}</span>
          <span v-else-if="state?.phase === 'reveal'">Answer: {{ question.correct }}</span>
          <span v-else-if="state?.phase === 'ended'">Game Over</span>
        </div>

        <div class="options">
          <button
            v-for="o in question.options"
            :key="o"
            :disabled="answered || state?.phase !== 'question'"
            :class="{ picked: picked === o }"
            @click="submitAnswer(o)"
          >
            {{ o }}
          </button>
        </div>

        <p v-if="answered">You picked: {{ picked }}</p>
      </div>

      <div v-else>
        <p v-if="state?.phase === 'lobby'">Waiting for host to start…</p>
        <p v-else>Loading question…</p>
      </div>
    </div>

    <aside>
      <Leaderboard :gameId="gid" />
    </aside>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useFirebase } from '~/plugins/firebase.client'
import { doc, onSnapshot, collection, onSnapshot as onColSnapshot, setDoc, serverTimestamp, getDoc } from 'firebase/firestore'
import Leaderboard from '~/components/Leaderboard.vue'

const { db, auth } = useFirebase()
const route = useRoute()
const gid = computed(() => String(route.params.id).toUpperCase())

const state = ref<any | null>(null)
const question = ref<any | null>(null)

const picked = ref<string | null>(null)
const answered = ref(false)

let unsubState: (() => void) | null = null
let unsubQuestion: (() => void) | null = null
let timer: number | null = null
const timeLeft = ref('')

function updateTimeLeft() {
  if (!state.value?.questionStartAt || !state.value?.questionDuration) {
    timeLeft.value = ''
    return
  }
  const start = state.value.questionStartAt.toDate ? state.value.questionStartAt.toDate() : new Date()
  const duration = Number(state.value.questionDuration) * 1000
  const end = start.getTime() + duration
  const ms = Math.max(0, end - Date.now())
  timeLeft.value = `${Math.ceil(ms / 1000)}s`
}

onMounted(() => {
  const stateRef = doc(db, 'games', gid.value, 'state', 'current')
  unsubState = onSnapshot(stateRef, async (s) => {
    state.value = s.data()
    // Load current question
    if (state.value?.currentQuestionId) {
      const qRef = doc(db, 'games', gid.value, 'questions', state.value.currentQuestionId)
      if (unsubQuestion) unsubQuestion()
      unsubQuestion = onSnapshot(qRef, (qs) => {
        question.value = qs.data()
      })
    } else {
      question.value = null
    }

    if (state.value?.phase === 'question') {
      answered.value = false
      picked.value = null
      if (timer) window.clearInterval(timer)
      timer = window.setInterval(updateTimeLeft, 200)
      updateTimeLeft()
    } else {
      if (timer) window.clearInterval(timer)
      timeLeft.value = ''
    }
  })
})

onUnmounted(() => {
  if (unsubState) unsubState()
  if (unsubQuestion) unsubQuestion()
  if (timer) window.clearInterval(timer)
})

async function submitAnswer(option: string) {
  if (answered.value) return
  const uid = auth.currentUser?.uid
  if (!uid) {
    alert('Not signed in yet, try again.')
    return
  }
  const qid = state.value?.currentQuestionId
  if (!qid) return
  picked.value = option

  const ansRef = doc(db, 'games', gid.value, 'questions', qid, 'answers', uid)
  try {
    await setDoc(ansRef, {
      answer: option,
      at: serverTimestamp(),
      name: (await getDoc(doc(db, 'games', gid.value, 'players', uid))).data()?.name || 'Player'
    })
    answered.value = true
  } catch (e) {
    console.error(e)
    alert('Could not submit answer (maybe already answered this question).')
  }
}
</script>

<style scoped>
.grid { display: grid; grid-template-columns: 1fr 300px; gap: 1rem; max-width: 1100px; margin: 2rem auto; padding: 0 1rem; }
.meta { margin: 0.5rem 0; }
.options { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 0.75rem; }
button.picked { background: #e5f2ff; }
aside { position: sticky; top: 1rem; height: fit-content; }
</style>

12) Try it locally

  1. Start Nuxt:
    • npm run dev
  2. Open two browser windows:
    • Window A (Host): go to /, click “Create Game” (copy ID), then open /host/ID
    • Window B (Player): join with the same ID and a nickname
  3. Host flow:
    • Start -> Question appears for all players
    • Reveal -> Cloud Function increments scores for correct answers
    • Next -> Moves to next question
    • End -> Finalizes the game
  4. Watch the leaderboard update in real time.

13) Optional hardening and polish

  • App Check: keep reCAPTCHA Enterprise site key in production. Use debug token locally if needed.
  • Auth providers: switch from Anonymous to Google or email if you want persistent identities across sessions.
  • Prevent answering after time: the rules block changing answers, but not timing. You can enforce timing on the server by ignoring late answers in the tally function (compare answer.at to state.questionStartAt + duration).
  • Deploy hosting: Firebase Hosting, Vercel, or Netlify work well with Nuxt 4. If using Firebase Hosting + Functions, set up rewrites to SSR if desired.

Conclusion

You’ve built a modern, real-time Trivia Quiz with Nuxt 4 and Firebase:

  • Nuxt 4 + TypeScript for a fast, DX-friendly UI
  • Firestore real-time snapshots for questions and leaderboards
  • Anonymous Auth + App Check (reCAPTCHA Enterprise) for low-friction, protected access
  • Cloud Functions v2 to securely tally scores on reveal
  • Firestore’s aggregate count() to show live player counts

Have fun extending it with richer host dashboards, animations, and custom categories!