Claude Code Plugins

Community-maintained marketplace

Feedback

firebase-realtime-patterns

@Teng-AI/mahjong
0
0

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.

Install Skill

1Download skill
2Enable skills in Claude

Open claude.ai/settings/capabilities and find the "Skills" section

3Upload to Claude

Click "Upload skill" and select the downloaded ZIP file

Note: Please verify skill by going through its instructions before using it.

SKILL.md

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:

  1. Database structure patterns
  2. Room management code
  3. State sync logic
  4. Security rules
  5. Connection handling