Implementing Turn-based Multiplayer with Firebase

2026-01-10

I recently decided to use the dice game Farkle (which I came across in Kingdom Come: Deliverance) as a small project to learn React and experiment with turn-based multipler. I wanted to host the game as a static page (Next.js export) which means there would be no traditional backend. Instead I wanted to use Firebase to handle authentication and real-time communication between players.

Firebase is a backend-as-a-service platform from Google with a free tier that is enough to implement multiplayer functionality. The two services we need from Firebase are authentication and a database. Authentication provides each player with a stable unique ID. The database can store the game state in a single document per lobby, only allowing updates from players inside that lobby. Clients switch turns by reacting to changes in the Firestore document.

All communication would be through the Firebase database instead of the clients communicating with each other directly:

Diagram showing how multiplayer clients communicate via Firebase database

Authentication

The main requirement for authentication is a stable user-id for each player so we can base database rules on the users. Anonymous sign-in works well for this since we don't need user accounts or persistence beyond the lobby session. If you were to extend the game to include statistics and longer term player data another sign-in method would probably be more useful.

I created this AuthProvider to wrap my game components:

import { createContext, useContext, useEffect, useState } from "react";
import { auth } from "../Services/firebase";
import { onAuthStateChanged, signInAnonymously } from "firebase/auth";

type AuthContextType = {
    user: typeof auth.currentUser | null;
    loading: boolean;
}

const AuthContext = createContext<AuthContextType>({
    user: null,
    loading: true,
});

export function AuthProvider({ children }: { children: React.ReactNode }) {
    const [user, setUser] = useState<typeof auth.currentUser | null>(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        const unsubscribe = onAuthStateChanged(auth, (user) => {
            setUser(user);
            setLoading(false);
        });

        if (!auth.currentUser) {
            signInAnonymously(auth)
                .then(() => {
                    // ...
                }).catch((error) => {
                    // ...
                });
        }
        return unsubscribe;
    }, []);

    return (<AuthContext value={{ user, loading }}>{children}</AuthContext>);
}

export function useAuth() {
    return useContext(AuthContext);
}

With this, any component can fetch the authentication details using the hook:

const { user, loading } = useAuth();

The game state is stored in a lobbies collection: Screenshot of a Farkle lobby showing players, scores, and active turn The lobby document contains the list of players, scores, the active turn, and an expiration timestamp which is updated on each turn.

When a user creates a lobby, a random UUID is generated and the user is added as a player inside the lobby. The user then gets a URL they can send to another player, for example: /farkle/multiplayer/lobby?lobbyId=3963a55e-280a-476f-a5bd-2e0285641b3c

When another player opens this link, the client attempts to join the lobby by adding their user-id to the players map if there is still room. The current implementation limits the players to two, but it would be trivial to add more players if needed.

To react to game state changes in real time I created a small custom hook that subscribes to the lobby document using Firestore snapshots:

import { useEffect, useState } from "react";
import LobbyData from "../Models/LobbyData";
import { doc, onSnapshot } from "firebase/firestore";
import { db } from "../Services/firebase";

export default function useLobby(lobbyId: string) {
    const [lobbyData, setLobbyData] = useState<LobbyData | null>(null);

    useEffect(() => {
        const lobbyRef = doc(db, "lobbies", lobbyId);
        const unsubscribe = onSnapshot(lobbyRef, (docSnap) => {
            if (docSnap.exists()) {
                setLobbyData(docSnap.data() as LobbyData);
            } else {
                setLobbyData(null);
            }
        });
        return unsubscribe;
    }, [lobbyId])

    return lobbyData;
}

This enables us to subscribe to game state updates from any component. This is used in the main game logic:

const lobbyData = useLobby(lobbyId, isMultiplayer());
// ...
useEffect(() => {
	const player = lobbyData.players[playerId];
	const otherPlayerId = Object.keys(lobbyData.players).find(id => id !== playerId);
	const otherPlayer = lobbyData.players[otherPlayerId];

	if (lobbyData.turn !== playerId) {
        // Logic depending on if it's our turn or not
	}

    // Local UI updates
	setTargetScore(lobbyData.target);
	setPlayerScore(player.score );
	setComputerScore(otherPlayer.score );
	if (!winCondition(player.score, otherPlayer.score)) {
		setPlayersTurn(lobbyData.turn !== playerId);
	}
}, [lobbyData, lobbyId, playerId]);

The useEffect hook will be triggered every time the lobby data is updated. When a player finishes their turn they will write the updated score and next turn to the lobby document. The other client receives this change via the snapshot listener and updates its UI.

Here is how the database is updated from the client after a player finishes their turn:

const uid = auth.currentUser.uid;
const lobbyRef = doc(db, "lobbies", lobbyId);
await updateDoc(lobbyRef, {
	[`players.${uid}.score`]: newPlayerScore,
	[`players.${uid}.lastSeen`]: new Date(),
	"turn": playerId,
	"expiresAt": new Date(Date.now() + EXPIRE_THRESHOLD_MS)
});

Rules

A critical part of using a Firebase database is to add rules to prevent abuse. In this example we will not support any protection against abuse from the opponent (a malicious player could manipulate their score or game state after their turn), but we will protect from abuse from any other player outside the lobby.

Each Firebase request includes the authenticated user-id which is the main mechanism used in the rules to restrict access. Our ruleset for this example will contain three things:

  • Users should be able to create new lobbies
  • Users should be allowed to modify lobbies they are a part of
  • Users should be able to delete expired lobbies

Every other operation is disallowed.

The reason for allowing deletion of lobbies is to have a way of cleaning up lobbies without a premium Firebase subscription or a proper backend. The way it's done in Farkle is that every time a user creates a new lobby the client code will check for expired lobbies. If any are found, they will send a delete request. To avoid players deleting active lobbies the Firebase rules will prevent any deletion of non-expired lobbies. Expiration is decided based on the expiresAt field, which is updated every time a move is done in the lobby. In this example we say any lobby that has been inactive for 10 minutes or more is expired, but this can be changed to any time.

This is an example of a ruleset that fulfills the requirements. The ruleset focuses on access control, not cheating prevention, and assumes players can trust each other:

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {  
    match /lobbies/{lobbyId} {
      allow create: if request.auth != null;
      allow read: if request.auth != null;      
      // Allow game state updates if the user is inside the lobby
      allow update: if request.auth != null && (
        request.resource.data.players[request.auth.uid] != null
        &&
        request.resource.data.players[request.auth.uid].score is int
        &&
        request.resource.data.players[request.auth.uid].lastSeen is timestamp
      )
      || (request.resource.data.keys().hasOnly(['turn']) && request.resource.data.turn is string && request.resource.data.turn in resource.data.players.keys())
      ;
      // Allow joining a lobby only if there is space
      allow update: if request.auth != null && (
        !(request.auth.uid in resource.data.players.keys())
        &&
        resource.data.players.size() < 2
      );
      // Allow deletion of expired lobbies
      allow delete: if request.auth != null &&
        resource.data.expiresAt < request.time - duration.value(10, "m");
    }
  }
}

Note that this is a minimal implementation and still allows some abuse. For example a player could modify the expiresAt field, making lobbies never expire. In a production scenario this logic would be better to move to a proper backend.

Summary

This was a short description of how Firebase can be used to implement a turn-based multiplayer game for a static web application without a traditional backend.

The full implementation of the Farkle dice game can be found on GitHub. If you want to try out the game yourself it's available here with the multiplayer functionality described above.


Back to posts.