name: firebase-realtime-patterns description: Patterns for real-time multiplayer game state using Firebase Realtime Database or Supabase. Use when implementing room systems, game state sync, or handling concurrent player actions.
Firebase Realtime Patterns for Mahjong
Patterns for implementing real-time multiplayer using Firebase Realtime Database (or Supabase).
When to Use
- Setting up room creation/joining
- Syncing game state across clients
- Handling concurrent player actions (calls)
- Managing connection states
- Implementing security rules
- Debugging sync issues
Database Structure
Room Document
// /rooms/{roomCode}
{
roomCode: "ABC123",
hostId: "player_uid_1",
createdAt: timestamp,
status: "waiting" | "playing" | "ended",
players: {
seat0: {
id: "player_uid_1",
name: "Player 1",
connected: true,
lastSeen: timestamp
},
seat1: { ... },
seat2: { ... },
seat3: { ... }
},
settings: {
dealerSeat: 0 // Host selected
},
game: {
// Only exists when status === "playing"
// See Game State below
}
}
Game State
// /rooms/{roomCode}/game
{
phase: "setup" | "bonus_exposure" | "playing" | "calling" | "ended",
goldTileType: "dots_5",
exposedGold: "dots_5_0", // Tile instance ID
wall: ["tile_id_1", "tile_id_2", ...], // Remaining tiles
discardPile: ["tile_id_x", "tile_id_y", ...],
currentPlayerSeat: 0,
dealerSeat: 0,
lastAction: {
type: "discard",
playerSeat: 0,
tile: "bamboo_3_2",
timestamp: timestamp
},
// Private hands stored separately (see below)
exposedMelds: {
seat0: [
{ type: "pung", tiles: ["dots_5_0", "dots_5_1", "dots_5_2"] }
],
seat1: [],
seat2: [],
seat3: []
},
bonusTiles: {
seat0: ["wind_east_0", "dragon_red_0"],
seat1: [],
seat2: [],
seat3: []
},
// For calling phase
pendingCalls: {
seat0: null, // Already discarded
seat1: "pung",
seat2: "pass",
seat3: null // Waiting for response
},
winner: null, // Set when game ends
scores: null // Set when game ends
}
Private Hands (Separate Path)
// /rooms/{roomCode}/privateHands/{seatNumber}
// Only readable by the player in that seat
{
concealedTiles: ["bamboo_1_0", "bamboo_2_1", "dots_5_3", ...]
}
Room Management
Create Room
import { ref, set, push, serverTimestamp } from 'firebase/database';
async function createRoom(db, hostUser) {
const roomCode = generateRoomCode(); // 6 char alphanumeric
const roomRef = ref(db, `rooms/${roomCode}`);
await set(roomRef, {
roomCode,
hostId: hostUser.uid,
createdAt: serverTimestamp(),
status: 'waiting',
players: {
seat0: {
id: hostUser.uid,
name: hostUser.displayName,
connected: true,
lastSeen: serverTimestamp()
},
seat1: null,
seat2: null,
seat3: null
},
settings: {
dealerSeat: 0
}
});
return roomCode;
}
function generateRoomCode() {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Avoid confusing chars
let code = '';
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
Join Room
import { ref, get, update, serverTimestamp } from 'firebase/database';
async function joinRoom(db, roomCode, user) {
const roomRef = ref(db, `rooms/${roomCode}`);
const snapshot = await get(roomRef);
if (!snapshot.exists()) {
throw new Error('Room not found');
}
const room = snapshot.val();
if (room.status !== 'waiting') {
throw new Error('Game already in progress');
}
// Find empty seat
const players = room.players;
let emptySeat = null;
for (let i = 0; i < 4; i++) {
if (!players[`seat${i}`]) {
emptySeat = i;
break;
}
}
if (emptySeat === null) {
throw new Error('Room is full');
}
// Join the seat
await update(ref(db, `rooms/${roomCode}/players/seat${emptySeat}`), {
id: user.uid,
name: user.displayName,
connected: true,
lastSeen: serverTimestamp()
});
return emptySeat;
}
Listen to Room Changes
import { ref, onValue, off } from 'firebase/database';
function subscribeToRoom(db, roomCode, callback) {
const roomRef = ref(db, `rooms/${roomCode}`);
const unsubscribe = onValue(roomRef, (snapshot) => {
if (snapshot.exists()) {
callback(snapshot.val());
}
});
return () => off(roomRef);
}
Game State Management
Start Game
async function startGame(db, roomCode, dealerSeat) {
// Generate and shuffle tiles
const tiles = generateTileIds();
const shuffled = shuffle(tiles);
// Deal tiles (16 each, 17 to dealer)
const hands = dealTiles(shuffled, dealerSeat);
const remainingWall = shuffled.slice(65); // 128 - 63 dealt
// Flip gold tile
const goldTile = remainingWall.shift();
const goldTileType = getTileType(goldTile);
// Initialize game state
const gameState = {
phase: 'bonus_exposure',
goldTileType,
exposedGold: goldTile,
wall: remainingWall,
discardPile: [],
currentPlayerSeat: dealerSeat,
dealerSeat,
lastAction: null,
exposedMelds: { seat0: [], seat1: [], seat2: [], seat3: [] },
bonusTiles: { seat0: [], seat1: [], seat2: [], seat3: [] },
bonusExposureComplete: { seat0: false, seat1: false, seat2: false, seat3: false },
pendingCalls: null,
winner: null,
scores: null
};
// Use transaction to update atomically
const updates = {
[`rooms/${roomCode}/status`]: 'playing',
[`rooms/${roomCode}/game`]: gameState,
[`rooms/${roomCode}/privateHands/seat0`]: { concealedTiles: hands[0] },
[`rooms/${roomCode}/privateHands/seat1`]: { concealedTiles: hands[1] },
[`rooms/${roomCode}/privateHands/seat2`]: { concealedTiles: hands[2] },
[`rooms/${roomCode}/privateHands/seat3`]: { concealedTiles: hands[3] }
};
await update(ref(db), updates);
}
function generateTileIds() {
const tiles = [];
// Suits
['dots', 'bamboo', 'characters'].forEach(suit => {
for (let num = 1; num <= 9; num++) {
for (let copy = 0; copy < 4; copy++) {
tiles.push(`${suit}_${num}_${copy}`);
}
}
});
// Winds (4 copies each)
['east', 'south', 'west', 'north'].forEach(dir => {
for (let copy = 0; copy < 4; copy++) {
tiles.push(`wind_${dir}_${copy}`);
}
});
// Red Dragons (4 copies)
for (let copy = 0; copy < 4; copy++) {
tiles.push(`dragon_red_${copy}`);
}
return tiles; // 128 tiles
}
function getTileType(tileId) {
// "dots_5_2" -> "dots_5"
const parts = tileId.split('_');
if (parts[0] === 'wind') {
return `wind_${parts[1]}`;
} else if (parts[0] === 'dragon') {
return `dragon_${parts[1]}`;
}
return `${parts[0]}_${parts[1]}`;
}
Player Actions
Discard Tile
async function discardTile(db, roomCode, playerSeat, tileId) {
const updates = {};
// Get current hand
const handRef = ref(db, `rooms/${roomCode}/privateHands/seat${playerSeat}`);
const handSnap = await get(handRef);
const hand = handSnap.val().concealedTiles;
// Remove tile from hand
const newHand = hand.filter(t => t !== tileId);
// Update state
updates[`rooms/${roomCode}/privateHands/seat${playerSeat}/concealedTiles`] = newHand;
updates[`rooms/${roomCode}/game/discardPile`] = arrayUnion(tileId);
updates[`rooms/${roomCode}/game/lastAction`] = {
type: 'discard',
playerSeat,
tile: tileId,
timestamp: serverTimestamp()
};
updates[`rooms/${roomCode}/game/phase`] = 'calling';
updates[`rooms/${roomCode}/game/pendingCalls`] = {
seat0: playerSeat === 0 ? 'discarder' : null,
seat1: playerSeat === 1 ? 'discarder' : null,
seat2: playerSeat === 2 ? 'discarder' : null,
seat3: playerSeat === 3 ? 'discarder' : null
};
await update(ref(db), updates);
}
Submit Call
async function submitCall(db, roomCode, playerSeat, callType) {
// callType: 'win' | 'pung' | 'chow' | 'pass'
await update(ref(db, `rooms/${roomCode}/game/pendingCalls/seat${playerSeat}`), callType);
// Check if all players have responded
const callsSnap = await get(ref(db, `rooms/${roomCode}/game/pendingCalls`));
const calls = callsSnap.val();
const allResponded = Object.values(calls).every(c => c !== null);
if (allResponded) {
await resolveCallS(db, roomCode, calls);
}
}
async function resolveCalls(db, roomCode, calls) {
const gameSnap = await get(ref(db, `rooms/${roomCode}/game`));
const game = gameSnap.val();
// Find discarder
const discarderSeat = Object.entries(calls).find(([_, v]) => v === 'discarder')[0].replace('seat', '');
// Priority: win > pung > chow
const priority = { win: 3, pung: 2, chow: 1, pass: 0, discarder: -1 };
let winner = null;
let highestPriority = -1;
for (const [seat, call] of Object.entries(calls)) {
const seatNum = parseInt(seat.replace('seat', ''));
const callPriority = priority[call] || 0;
if (callPriority > highestPriority) {
highestPriority = callPriority;
winner = { seat: seatNum, call };
} else if (callPriority === highestPriority && callPriority > 0) {
// Tie-breaker: closest to discarder (counter-clockwise)
const currentDistance = (seatNum - discarderSeat + 4) % 4;
const winnerDistance = (winner.seat - discarderSeat + 4) % 4;
if (currentDistance < winnerDistance) {
winner = { seat: seatNum, call };
}
}
}
if (winner && winner.call !== 'pass') {
await executeCall(db, roomCode, winner, game);
} else {
// Everyone passed, next player's turn
const nextSeat = (parseInt(discarderSeat) + 3) % 4; // Counter-clockwise
await update(ref(db, `rooms/${roomCode}/game`), {
phase: 'playing',
currentPlayerSeat: nextSeat,
pendingCalls: null
});
}
}
Security Rules
// Firebase Realtime Database Rules
{
"rules": {
"rooms": {
"$roomCode": {
// Anyone can read room metadata
".read": true,
// Only host can write room settings
"settings": {
".write": "data.parent().child('hostId').val() === auth.uid"
},
// Players can join empty seats
"players": {
"$seat": {
".write": "(!data.exists() && newData.child('id').val() === auth.uid) || data.child('id').val() === auth.uid"
}
},
// Private hands only readable by owner
"privateHands": {
"$seat": {
".read": "root.child('rooms').child($roomCode).child('players').child($seat).child('id').val() === auth.uid",
".write": "root.child('rooms').child($roomCode).child('players').child($seat).child('id').val() === auth.uid || root.child('rooms').child($roomCode).child('hostId').val() === auth.uid"
}
},
// Game state - validated writes
"game": {
".read": true,
".write": "auth !== null && root.child('rooms').child($roomCode).child('players').hasChild('seat0') && root.child('rooms').child($roomCode).child('players').hasChild('seat1') && root.child('rooms').child($roomCode).child('players').hasChild('seat2') && root.child('rooms').child($roomCode).child('players').hasChild('seat3')"
}
}
}
}
}
Connection Handling
Presence System
import { ref, onDisconnect, set, serverTimestamp, onValue } from 'firebase/database';
function setupPresence(db, roomCode, seatNumber, userId) {
const presenceRef = ref(db, `rooms/${roomCode}/players/seat${seatNumber}/connected`);
const lastSeenRef = ref(db, `rooms/${roomCode}/players/seat${seatNumber}/lastSeen`);
// Set connected to true
set(presenceRef, true);
// On disconnect, set to false
onDisconnect(presenceRef).set(false);
onDisconnect(lastSeenRef).set(serverTimestamp());
// Listen for connection state
const connectedRef = ref(db, '.info/connected');
onValue(connectedRef, (snap) => {
if (snap.val() === true) {
set(presenceRef, true);
set(lastSeenRef, serverTimestamp());
}
});
}
Reconnection
async function rejoinRoom(db, roomCode, userId) {
const roomSnap = await get(ref(db, `rooms/${roomCode}`));
if (!roomSnap.exists()) {
throw new Error('Room no longer exists');
}
const room = roomSnap.val();
// Find player's seat
let playerSeat = null;
for (let i = 0; i < 4; i++) {
if (room.players[`seat${i}`]?.id === userId) {
playerSeat = i;
break;
}
}
if (playerSeat === null) {
throw new Error('You are not in this room');
}
// Update connection status
await update(ref(db, `rooms/${roomCode}/players/seat${playerSeat}`), {
connected: true,
lastSeen: serverTimestamp()
});
return {
seat: playerSeat,
room,
game: room.game
};
}
Debugging Patterns
Log State Changes
function debugSubscribe(db, roomCode) {
onValue(ref(db, `rooms/${roomCode}/game`), (snap) => {
console.log('[GAME STATE]', JSON.stringify(snap.val(), null, 2));
});
onValue(ref(db, `rooms/${roomCode}/game/phase`), (snap) => {
console.log('[PHASE CHANGE]', snap.val());
});
onValue(ref(db, `rooms/${roomCode}/game/pendingCalls`), (snap) => {
console.log('[PENDING CALLS]', snap.val());
});
}
Validate State Consistency
function validateGameState(game, privateHands) {
const errors = [];
// Check tile counts
let totalTiles = 0;
totalTiles += game.wall.length;
totalTiles += game.discardPile.length;
totalTiles += 1; // Exposed gold
for (let i = 0; i < 4; i++) {
totalTiles += privateHands[`seat${i}`].concealedTiles.length;
totalTiles += game.exposedMelds[`seat${i}`].reduce((sum, m) => sum + m.tiles.length, 0);
totalTiles += game.bonusTiles[`seat${i}`].length;
}
if (totalTiles !== 128) {
errors.push(`Tile count mismatch: ${totalTiles} (expected 128)`);
}
// Check for duplicate tiles
const allTiles = [
...game.wall,
...game.discardPile,
game.exposedGold,
...Object.values(privateHands).flatMap(h => h.concealedTiles),
...Object.values(game.exposedMelds).flatMap(melds => melds.flatMap(m => m.tiles)),
...Object.values(game.bonusTiles).flat()
];
const seen = new Set();
for (const tile of allTiles) {
if (seen.has(tile)) {
errors.push(`Duplicate tile: ${tile}`);
}
seen.add(tile);
}
return errors;
}
Supabase Alternative
If using Supabase instead of Firebase:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
// Subscribe to room changes
const subscription = supabase
.channel(`room:${roomCode}`)
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'rooms', filter: `code=eq.${roomCode}` },
(payload) => {
console.log('Room changed:', payload);
}
)
.subscribe();
// Real-time game state with Supabase Realtime
const gameSubscription = supabase
.channel(`game:${roomCode}`)
.on('broadcast', { event: 'game_update' }, (payload) => {
updateGameState(payload.game);
})
.subscribe();
// Broadcast game update
await supabase.channel(`game:${roomCode}`).send({
type: 'broadcast',
event: 'game_update',
payload: { game: newGameState }
});
Usage
When implementing multiplayer features, reference this skill for:
- Database structure patterns
- Room management code
- State sync logic
- Security rules
- Connection handling