A Jackbox.tv Clone with Supabase and Next.js.

himuhimu
25 min read

Game Night Hero: The Journey Begins

It was another Friday night, and as usual, my friends and I were huddled around a screen playing Jackbox games. The laughter, the creativity, the friendly competition—it's always a highlight of our week. As a developer, I couldn't help but wonder: "Could I build something like this?" That curiosity sparked a weekend project that turned into SupaBox TV—a Jackbox-inspired party game built with Next.js and Supabase.

In this post, I'll walk you through how I created a real-time multiplayer game that lets friends join a virtual room, answer prompts, vote on the funniest responses, and crown a winner.

Level 1: Project Overview

Before diving into the code, let's look at what we're building:

- A multiplayer party game where players join a room using a 4-character code
- Players answer creative prompts and vote for their favorite answers
- Real-time updates so all players see the same game state
- Multiple game phases: lobby, answering, voting, and results
- Score tracking and final results after multiple rounds

The tech stack:
- **Next.js**: For the frontend and API routes
- **Supabase**: For authentication, database, and real-time subscriptions
- **Shadcn UI**: For neobrutalism-styled components
- **TypeScript**: For type safety

Level 2: Setting Up the Game Board

Clone the starter template here - saas

While we're implementing the standard Sign in with Google authentication (yawn, how original), the real magic of Supabox TV lies elsewhere! We've decided to keep the authentication boring and predictable—like that friend who always orders vanilla ice cream—so we can focus our creative energy on the parts that actually matter: creating virtual living rooms where friends can gather to make terrible jokes, vote on who has the worst sense of humor, and silently judge each other's answers while pretending to be supportive.

Realtime Room Management Implementation Guide for Supabox TV

This guide provides a detailed walkthrough of the room management implementation in the Supabox TV project, including room creation, joining, and real-time subscriptions.

Putting It All Together

The final game flow works like this:

  1. Players join a room using a 4-character code

  2. The host starts the game when everyone is ready

  3. Players answer creative prompts

  4. When all players have answered, the game automatically moves to voting

  5. Players vote for their favorite answers (not their own)

  6. Votes are tallied and displayed

  7. The game moves to the next round or shows final results

  8. A winner is crowned based on total votes received

1. Database Schema

The room management system relies on two primary tables:

-- Rooms table
CREATE TABLE rooms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code TEXT UNIQUE NOT NULL,
  host_id UUID REFERENCES auth.users(id) NOT NULL,
  current_state TEXT NOT NULL DEFAULT 'lobby', -- lobby, answering, voting, results
  created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);

-- Players table
CREATE TABLE players (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  room_id UUID REFERENCES rooms(id) ON DELETE CASCADE NOT NULL,
  user_id UUID REFERENCES auth.users(id),
  nickname TEXT NOT NULL,
  is_host BOOLEAN NOT NULL DEFAULT false,
  is_active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);

2. Row Level Security (RLS) Policies

Security is implemented using Supabase's Row Level Security:

-- Enable RLS on tables
ALTER TABLE rooms ENABLE ROW LEVEL SECURITY;
ALTER TABLE players ENABLE ROW LEVEL SECURITY;

-- RLS policies would typically include:
-- 1. Allow users to create rooms
-- 2. Allow users to view rooms they are in
-- 3. Allow users to join rooms with valid codes
-- 4. Allow users to view/update their own player data

3. Game Utility Functions

Room Code Generation

Ever tried to tell your friends a complicated room code over the phone? Yeah, me too. That's why I wanted something short and memorable, but still unique enough to avoid collisions. My solution was a simple 4-character alphanumeric code generator that skips confusing characters like "O" and "0". It's amazing how something so simple becomes the gateway to all the fun that follows.

// src/lib/game-utils.ts
export function generateRoomCode(): string {
  const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed similar looking characters
  let result = '';
  for (let i = 0; i < 4; i++) {
    result += characters.charAt(Math.floor(Math.random() * characters.length));
  }
  return result;
}

Room Creation

Creating a room needed to be as effortless as possible—no one wants to fill out forms when they're ready to play. I designed the room creation process to be a one-click affair that generates a unique code, sets up the database entry, and prepares the virtual space where all the magic happens. Behind that simple "Create Room" button lies a carefully orchestrated dance of database operations.

// src/lib/game-utils.ts
export async function createRoom(hostId: string): Promise<Room | null> {
  try {
    const roomCode = generateRoomCode();

    const { data: room, error } = await supabase
      .from('rooms')
      .insert({
        code: roomCode,
        host_id: hostId,
        current_state: 'lobby'
      })
      .select()
      .single();

    if (error) throw error;
    return room;
  } catch (error) {
    console.error('Error creating room:', error);
    return null;
  }
}

Room Joining

"Hey, use code XQTZ to join my game!" The joining experience needed to be frictionless—just a code and a nickname. But underneath this simplicity, there's a robust system checking if the room exists, if the player is already in it (maybe they got disconnected?), and handling edge cases like rejoining a game in progress. It's like being a digital doorman for your friends' game night.

// src/lib/game-utils.ts
export async function joinRoom(roomCode: string, userId: string, nickname: string): Promise<{ room: Room; player: Player } | null> {
  try {
    // First, find the room by code
    const { data: rooms, error: roomError } = await supabase
      .from('rooms')
      .select('*')
      .eq('code', roomCode);

    if (roomError) throw roomError;

    // Check if we found a room
    if (!rooms || rooms.length === 0) {
      console.error('Room not found with code:', roomCode);
      return null;
    }

    const room = rooms[0];

    // Check if user is already in the room
    const { data: existingPlayers, error: existingPlayerError } = await supabase
      .from('players')
      .select('*')
      .eq('room_id', room.id)
      .eq('user_id', userId);

    if (existingPlayerError) throw existingPlayerError;

    // If player already exists in the room
    if (existingPlayers && existingPlayers.length > 0) {
      const existingPlayer = existingPlayers[0];

      // If player exists but is inactive, reactivate them
      if (!existingPlayer.is_active) {
        const { data: updatedPlayers, error: updateError } = await supabase
          .from('players')
          .update({ is_active: true })
          .eq('id', existingPlayer.id)
          .select();

        if (updateError) throw updateError;

        if (updatedPlayers && updatedPlayers.length > 0) {
          return { room, player: updatedPlayers[0] };
        }
      }

      return { room, player: existingPlayer };
    }

    // Add player to the room
    const isHost = room.host_id === userId;

    const { data: newPlayers, error: playerError } = await supabase
      .from('players')
      .insert({
        room_id: room.id,
        user_id: userId,
        nickname,
        is_host: isHost,
        is_active: true
      })
      .select();

    if (playerError) throw playerError;

    if (newPlayers && newPlayers.length > 0) {
      return { room, player: newPlayers[0] };
    }

    throw new Error('Failed to create player');
  } catch (error) {
    console.error('Error joining room:', error);
    return null;
  }
}

Leaving a Room

All good things must come to an end, but what happens when the host leaves? Or when a player rage-quits after their brilliant joke gets zero votes? I had to carefully consider the ripple effects of someone leaving—especially if that someone created the room. The leaving mechanism became more complex than I initially thought, but these details make the difference between a smooth experience and a frustrating one.

// src/lib/game-utils.ts
export async function leaveRoom(playerId: string): Promise<boolean> {
  try {
    // First, get the player to check if they're the host
    const { data: players, error: playerError } = await supabase
      .from('players')
      .select('*, rooms(*)')
      .eq('id', playerId);

    if (playerError) throw playerError;

    // If no player found, return false
    if (!players || players.length === 0) {
      console.error('Player not found:', playerId);
      return false;
    }

    const player = players[0];

    // If the player is the host, set all players in the room to inactive
    if (player && player.is_host) {
      console.log('Host is leaving room:', player.room_id);

      // First update the room to indicate host has left
      const { error: roomUpdateError } = await supabase
        .from('rooms')
        .update({ 
          host_left: true,
          current_state: 'lobby',
          updated_at: new Date().toISOString() 
        })
        .eq('id', player.room_id);

      if (roomUpdateError) throw roomUpdateError;

      // Update all players in the room to inactive
      const { error: updateAllError } = await supabase
        .from('players')
        .update({ is_active: false })
        .eq('room_id', player.room_id);

      if (updateAllError) throw updateAllError;

      return true;
    } else {
      // If not the host, just set this player to inactive
      const { error } = await supabase
        .from('players')
        .update({ is_active: false })
        .eq('id', playerId);

      if (error) throw error;
      return true;
    }
  } catch (error) {
    console.error('Error leaving room:', error);
    return false;
  }
}

4. UI Components

Create Game Dialog

The gateway to fun needed to look the part. I wanted the Create Game dialog to be inviting, simple, and get players into the action with minimal friction. It's like the digital equivalent of opening the game box on game night—it should build anticipation without getting in the way of the fun that's about to happen.

// src/app/dashboard/page.tsx
function CreateGameDialog({ userId }: { userId: string }) {
  const [isCreating, setIsCreating] = useState(false)
  const [nickname, setNickname] = useState('')
  const [open, setOpen] = useState(false)
  const router = useRouter()

  const handleCreateRoom = async () => {
    if (!nickname.trim()) return

    setIsCreating(true)
    try {
      const room = await createRoom(userId)
      if (room) {
        // Join the room as the host
        const result = await joinRoom(room.code, userId, nickname)
        if (result) {
          router.push(`/game/${room.code}`)
        }
      }
    } catch (error) {
      console.error('Error creating room:', error)
    } finally {
      setIsCreating(false)
      setOpen(false)
    }
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>Create Game</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Create a New Game</DialogTitle>
          <DialogDescription>
            Create a new game room and invite your friends to join.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="nickname" className="col-span-4">
              Your Nickname
            </Label>
            <Input
              id="nickname"
              value={nickname}
              onChange={(e) => setNickname(e.target.value)}
              className="col-span-4"
              placeholder="Enter your nickname"
            />
          </div>
        </div>
        <DialogFooter>
          <Button onClick={handleCreateRoom} disabled={isCreating || !nickname.trim()}>
            {isCreating ? 'Creating...' : 'Create Room'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

Join Game Dialog

"What's the code again?" The Join Game dialog is where friendships are tested as people try to correctly type in four characters. I focused on making this screen forgiving (with clear error messages) and quick to use. It's the digital equivalent of pulling up a chair to the table—it should feel welcoming and straightforward.

// src/app/dashboard/page.tsx
function JoinGameDialog({ userId }: { userId: string }) {
  const [isJoining, setIsJoining] = useState(false)
  const [roomCode, setRoomCode] = useState('')
  const [nickname, setNickname] = useState('')
  const [open, setOpen] = useState(false)
  const [joinError, setJoinError] = useState('')
  const router = useRouter()

  const handleJoinRoom = async () => {
    if (!roomCode.trim() || !nickname.trim()) return

    setIsJoining(true)
    setJoinError('')

    try {
      const result = await joinRoom(roomCode.toUpperCase(), userId, nickname)
      if (result) {
        router.push(`/game/${result.room.code}`)
        setOpen(false) // Only close the dialog on success
      } else {
        // Room not found or other error
        setJoinError('Room not found. Please check the room code and try again.')
        setIsJoining(false)
      }
    } catch (error) {
      console.error('Error joining room:', error)
      setJoinError('An error occurred while joining the room. Please try again.')
      setIsJoining(false)
    }
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button>Join Game</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Join a Game</DialogTitle>
          <DialogDescription>
            Enter a room code to join an existing game.
          </DialogDescription>
        </DialogHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="room-code" className="col-span-4">
              Room Code
            </Label>
            <Input
              id="room-code"
              value={roomCode}
              onChange={(e) => setRoomCode(e.target.value)}
              className="col-span-4"
              placeholder="Enter 4-letter room code"
              maxLength={4}
            />
          </div>
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="join-nickname" className="col-span-4">
              Your Nickname
            </Label>
            <Input
              id="join-nickname"
              value={nickname}
              onChange={(e) => setNickname(e.target.value)}
              className="col-span-4"
              placeholder="Enter your nickname"
            />
          </div>
        </div>
        <DialogFooter className="flex flex-col items-stretch">
          {joinError && (
            <div className="text-red-500 text-sm mb-3 text-center">{joinError}</div>
          )}
          <Button 
            onClick={handleJoinRoom} 
            disabled={isJoining || !roomCode.trim() || !nickname.trim()}
          >
            {isJoining ? 'Joining...' : 'Join Room'}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

5. Real-Time Subscriptions

The game room implements real-time subscriptions to keep all players synchronized:

Nothing kills the vibe faster than someone asking, "Wait, are we voting now?" Real-time synchronization is the invisible thread that keeps everyone on the same page. Setting up Supabase subscriptions was like hiring a super-efficient party host who makes sure everyone knows what's happening at all times—without ever being noticed themselves.

// src/app/game/[code]/page.tsx
useEffect(() => {
  if (loading) return

  if (!user) {
    router.push('/')
    return
  }

  // Fetch initial room data
  const fetchRoomData = async () => {
    // Implementation details...
  }

  fetchRoomData()

  // Subscribe to player changes in this room
  const playersSubscription = supabase
    .channel(`room:${roomCode}`)
    .on('postgres_changes', {
      event: '*',
      schema: 'public',
      table: 'players',
      filter: `room_id=eq.${room?.id}`
    }, async (payload) => {
      // Refresh the players list
      const updatedPlayers = await getPlayersInRoom(room?.id || '');
      setPlayers(updatedPlayers);

      // Check if current player is still active
      const isCurrentPlayerActive = updatedPlayers.some(p => p.user_id === user.id);
      if (!isCurrentPlayerActive) {
        // Current player has been set to inactive (possibly by host leaving)
        router.push('/dashboard');
        return;
      }

      // Check if host is still in the game
      const isHostActive = updatedPlayers.some(p => p.is_host);
      if (!isHostActive && currentPlayer && !currentPlayer.is_host) {
        // Host has left and current player is not the host
        alert('The host has left the game. You will be redirected to the dashboard.');
        router.push('/dashboard');
      }
    })
    .subscribe()

  // Subscribe to room state changes
  const roomSubscription = supabase
    .channel(`room-state:${roomCode}`)
    .on('postgres_changes', {
      event: 'UPDATE',
      schema: 'public',
      table: 'rooms',
      filter: `id=eq.${room?.id}`
    }, (payload) => {
      // Update room state and handle state transitions
      // ...
    })
    .subscribe()

  // Clean up subscriptions when component unmounts
  return () => {
    playersSubscription.unsubscribe()
    roomSubscription.unsubscribe()
    // Other subscriptions cleanup...
  }
}, [user, loading, roomCode, router, room?.id])

Here's a comprehensive guide to how the game flow is implemented in Supabox TV, including state management, round tracking, and real-time subscriptions.

1. Game State Management

The game follows a state machine pattern with four distinct states:

Game night chaos needs structure underneath. I found that a state machine approach—where the game moves predictably from lobby to answering to voting to results—created just the right balance of order and spontaneity. It's like having the rules of the game clearly defined, so players can focus on being creative rather than figuring out what comes next.

type GameState = 'lobby' | 'answering' | 'voting' | 'results';

State Transitions

  1. Lobby → Answering: When the host clicks "Start Game"

  2. Answering → Voting: When all players have submitted answers

  3. Voting → Results: When all players have voted

  4. Results → Answering: When starting a new round

  5. Results → Lobby: When returning to the lobby after game completion

    1. Lobby → Answering: When the host clicks "Start Game"
    1. Answering → Voting: When all players have submitted answers
    1. Voting → Results: When all players have voted
    1. Results → Answering: When starting a new round
    1. Results → Lobby: When returning to the lobby after game completion

The state is stored in the current_state column of the rooms table and managed through the updateRoomState function:

export async function updateRoomState(roomId: string, newState: 'lobby' | 'answering' | 'voting' | 'results'): Promise<Room | null> {
  try {
    const { data, error } = await supabase
      .from('rooms')
      .update({ current_state: newState, updated_at: new Date().toISOString() })
      .eq('id', roomId)
      .select();

    if (error) throw error;

    if (data && data.length > 0) {
      return data[0];
    }

    return null;
  } catch (error) {
    console.error('Error updating room state:', error);
    return null;
  }
}

2. Round Tracking

Rounds are tracked in the current_round column of the rooms table. The game is configured to have a maximum number of rounds (defined as MAX_ROUNDS in the game room component):

"One more round!" is the battle cry of every good game night. Tracking rounds became essential not just for game progression but for building anticipation—that feeling of "we're halfway through" or "last round, make it count!" The database quietly keeps score while players focus on outdoing each other's answers.

// In game/[code]/page.tsx
const [currentRound, setCurrentRound] = useState(1)
const MAX_ROUNDS = 1

Round updates are managed through the updateRoomRound function:

export async function updateRoomRound(roomId: string, roundNumber: number): Promise<Room | null> {
  try {
    // First update the room without trying to return the updated data
    const { error: updateError } = await supabase
      .from('rooms')
      .update({ 
        current_round: roundNumber,
        updated_at: new Date().toISOString() 
      })
      .eq('id', roomId);

    if (updateError) throw updateError;

    // Then fetch the updated room data separately
    const { data, error } = await supabase
      .from('rooms')
      .select('*')
      .eq('id', roomId);

    if (error) throw error;

    if (data && data.length > 0) {
      return data[0];
    }

    return null;
  } catch (error) {
    console.error('Error updating room round:', error);
    return null;
  }
}

3. Game Flow Implementation

The heart of any party game is its flow—that invisible current that carries players from one moment of fun to the next. Implementing this flow meant connecting all the pieces: prompts, answers, votes, and results, into a seamless experience that feels natural. It's digital choreography where the best code is the code players never notice.

A. Game Room Component Initialization

When a player enters a game room, the component initializes by:

  1. Fetching room data

  2. Loading players

  3. Setting up real-time subscriptions

  4. Identifying the current player

  5. Fetching room data

    1. Loading players

    2. Setting up real-time subscriptions

    3. Identifying the current player

useEffect(() => {
  if (loading) return

  if (!user) {
    router.push('/')
    return
  }

  const fetchRoomData = async () => {
    setIsLoading(true)
    try {
      const roomData = await getRoomByCode(roomCode)
      if (!roomData) {
        router.push('/dashboard')
        return
      }

      setRoom(roomData)

      // If the room has a prompt, set it in the state
      if (roomData.current_prompt_id && roomData.current_prompt_text) {
        setCurrentPrompt({
          id: roomData.current_prompt_id,
          text: roomData.current_prompt_text
        })

        // If the room is in voting or results phase, load the answers
        if (roomData.current_state === 'voting' || roomData.current_state === 'results') {
          loadAnswersForVoting();
        }
      }

      // Set the current round from the room data
      if (roomData.current_round) {
        setCurrentRound(roomData.current_round);
      }

      const playersData = await getPlayersInRoom(roomData.id)
      setPlayers(playersData)

      const currentPlayerData = playersData.find(p => p.user_id === user.id) || null
      setCurrentPlayer(currentPlayerData)

      if (!currentPlayerData) {
        // If user is not in this room, redirect to dashboard
        router.push('/dashboard')
        return
      }
    } catch (error) {
      console.error('Error fetching room data:', error)
      router.push('/dashboard')
    } finally {
      setIsLoading(false)
    }
  }

  fetchRoomData()
}, [user, loading, roomCode, router, room?.id])

B. Starting the Game (Lobby → Answering)

When the host clicks "Start Game", the following happens:

  1. A random prompt is fetched

  2. The round counter is initialized to 1

  3. The prompt is saved to the room

  4. The room state is updated to 'answering'

  5. A random prompt is fetched

  6. The round counter is initialized to 1

  7. The prompt is saved to the room

  8. The room state is updated to 'answering'

{currentPlayer.is_host && (
  <Button 
    onClick={async () => {
      if (players.length < 2) {
        alert('You need at least 2 players to start the game');
        return;
      }

      // Get a random prompt when starting the game
      const prompt = await getRandomPrompt();
      if (prompt) {
        // Save the prompt to local state
        setCurrentPrompt(prompt);

        // Initialize the round counter to 1
        await updateRoomRound(room.id, 1);
        setCurrentRound(1);

        // Save the prompt to the database so all players can see it
        await updateRoomPrompt(room.id, prompt.id, prompt.text);

        // Update the room state to answering
        const updatedRoom = await updateRoomState(room.id, 'answering');
        if (updatedRoom) {
          setRoom(updatedRoom);
        }
      } else {
        alert('Failed to get a prompt. Please try again.');
      }
    }}
  >
    Start Game
  </Button>
)}

C. Answering Phase

Players submit answers to the prompt:

The blank text box. The blinking cursor. The moment of panic when you can't think of anything funny. The answering phase is where personalities shine through—some typing furiously within seconds, others agonizing over word choice until the last moment. Creating this phase meant balancing time pressure with creative space.

const handleSubmitAnswer = async () => {
  if (!playerAnswer.trim() || !currentPrompt) return;

  // Save the player's answer
  const saved = await savePlayerAnswer(
    currentPlayer.id,
    room.id,
    currentPrompt.id,
    playerAnswer.trim()
  );

  if (saved) {
    // Disable the button after submitting
    setPlayerAnswer('Thanks for your answer! Waiting for other players...');

    // Broadcast a message to all clients that an answer was submitted
    await supabase
      .channel(`answers:${roomCode}`)
      .send({
        type: 'broadcast',
        event: 'answer_submitted',
        payload: { 
          player_id: currentPlayer.id,
          room_id: room.id,
          prompt_id: currentPrompt.id 
        }
      });

    // If the player is the host, manually check if all players have answered
    if (currentPlayer?.is_host) {
      checkAndUpdateGameState();
    }
  } else {
    alert('Failed to submit your answer. Please try again.');
  }
}

D. Transition to Voting Phase

When all players have submitted answers, the game transitions to the voting phase:

The anticipation builds as the last player finally submits their answer. The transition to voting is that collective breath before the reveal—the moment when everyone wonders "did I write something funny enough?" Getting this transition right was crucial for building tension and excitement.

const checkAndUpdateGameState = async () => {
  console.log('Checking game state...');
  if (!room || !currentPrompt || !currentPlayer?.is_host || room.current_state !== 'answering') {
    console.log('Cannot check game state:', { 
      roomExists: !!room, 
      promptExists: !!currentPrompt, 
      isHost: currentPlayer?.is_host, 
      currentState: room?.current_state 
    });
    return;
  }

  console.log('Checking if all players have answered...');
  const allAnswered = await haveAllPlayersAnswered(room.id, currentPrompt.id);
  console.log('All players answered?', allAnswered);

  if (allAnswered) {
    console.log('All players have answered, moving to voting phase');
    const updatedRoom = await updateRoomState(room.id, 'voting');
    console.log('Room updated to voting phase:', updatedRoom);
    if (updatedRoom) {
      setRoom(updatedRoom);

      // Load answers for voting phase
      loadAnswersForVoting();
    }
  } else {
    console.log('Not all players have submitted their answers yet');
    alert('Not all players have submitted their answers yet. Please wait.');
  }
}

E. Voting Phase

Players vote for their favorite answers (not their own):

Judgment time! The voting phase transforms friends into critics, carefully weighing each anonymous response. I wanted this phase to feel like a mini award show—each answer gets its moment in the spotlight before votes are cast. The rule against voting for your own answer? That's just to prevent the inevitable "I'm the funniest person I know" scenario.

const handleVote = async (answerId: string) => {
  if (!room || !currentPlayer || hasVoted || isSubmittingVote) return;

  // Don't allow voting for your own answer
  const isOwnAnswer = answers.some(answer => 
    answer.id === answerId && answer.player_id === currentPlayer.id
  );

  if (isOwnAnswer) {
    alert('You cannot vote for your own answer!');
    return;
  }

  setIsSubmittingVote(true);

  console.log('Submitting vote for answer:', answerId);
  const success = await submitVote(currentPlayer.id, answerId, room.id);

  if (success) {
    setHasVoted(true);

    // If the player is the host, check if all players have voted
    if (currentPlayer.is_host) {
      checkIfAllPlayersVoted();
    }
  } else {
    alert('Failed to submit your vote. Please try again.');
  }

  setIsSubmittingVote(false);
}

F. Transition to Results Phase

When all players have voted, the game transitions to the results phase:

The drumroll moment. As the last vote comes in, there's that perfect pause before the big reveal. This transition needed to build suspense while calculations happen behind the scenes. It's like the host of a game show opening the envelope—a moment designed for maximum dramatic effect.

const checkIfAllPlayersVoted = async () => {
  if (!room || !currentPlayer?.is_host || room.current_state !== 'voting') return;

  console.log('Checking if all players have voted...');
  const allVoted = await haveAllPlayersVoted(room.id);
  console.log('All players voted?', allVoted);

  if (allVoted) {
    // Update player scores before changing to results
    updatePlayerScores();

    console.log('All players have voted, moving to results phase');
    const updatedRoom = await updateRoomState(room.id, 'results');
    console.log('Room updated to results phase:', updatedRoom);
    if (updatedRoom) {
      setRoom(updatedRoom);
    }
  } else {
    console.log('Not all players have voted yet');
  }
}

G. Next Round or Game End

After the results phase, the host can start the next round or end the game:

"Should we play again?" The post-round decision point is crucial for maintaining momentum. I designed this moment to celebrate the round's winners while dangling the next prompt as temptation. For the final round, there's the satisfying closure of seeing the ultimate champion crowned—complete with emoji confetti, of course.

{currentPlayer?.is_host && (
  <div className="mt-8 flex justify-center space-x-4">
    {currentRound < MAX_ROUNDS ? (
      <Button 
        onClick={async () => {
          // Clear votes from previous round
          await clearVotesForRoom(room.id);

          // Get a new random prompt
          const prompt = await getRandomPrompt();
          if (prompt) {
            // Increment round counter
            const nextRound = currentRound + 1;

            // Update the round in the database
            await updateRoomRound(room.id, nextRound);

            // Save the prompt to local state
            setCurrentPrompt(prompt);

            // Save the prompt to the database
            await updateRoomPrompt(room.id, prompt.id, prompt.text);

            // Reset player answer and clear any previous submission message
            setPlayerAnswer('');

            // Reset voting state
            setHasVoted(false);
            setAnswers([]);
            setIsSubmittingVote(false);

            // Update local round counter
            setCurrentRound(nextRound);

            // Update the room state to answering
            const updatedRoom = await updateRoomState(room.id, 'answering');
            if (updatedRoom) {
              setRoom(updatedRoom);
            }
          } else {
            alert('Failed to get a new prompt. Please try again.');
          }
        }}
      >
        Next Round ({currentRound}/{MAX_ROUNDS})
      </Button>
    ) : (
      <Button
        variant="default"
        onClick={() => {
          setShowFinalResults(true);
        }}
      >
        Show Final Results
      </Button>
    )}
  </div>
)}

4. Real-time Subscriptions

The game uses Supabase's real-time subscriptions to keep all players synchronized:

In a physical party game, everyone sees the same board, the same cards, the same timer. Recreating that shared reality online required a nervous system of real-time updates. Supabase subscriptions became the digital equivalent of "everyone look at the board now"—ensuring no player is left behind as the game progresses.

A. Player Subscriptions

Monitors player changes in the room:

"Oh, Sarah just joined!" Player subscriptions create that sense of presence—the digital equivalent of seeing someone walk through the door. They also handle the awkward moment when someone's connection drops or they leave unexpectedly. It's like having eyes on the door throughout game night.

const playersSubscription = supabase
  .channel(`room:${roomCode}`)
  .on('postgres_changes', {
    event: '*',
    schema: 'public',
    table: 'players',
    filter: `room_id=eq.${room?.id}`
  }, async (payload) => {
    // Refresh the players list
    const updatedPlayers = await getPlayersInRoom(room?.id || '');
    setPlayers(updatedPlayers);

    // Check if current player is still active
    const isCurrentPlayerActive = updatedPlayers.some(p => p.user_id === user.id);
    if (!isCurrentPlayerActive) {
      // Current player has been set to inactive (possibly by host leaving)
      router.push('/dashboard');
      return;
    }

    // Check if host is still in the game
    const isHostActive = updatedPlayers.some(p => p.is_host);
    if (!isHostActive && currentPlayer && !currentPlayer.is_host) {
      // Host has left and current player is not the host
      alert('The host has left the game. You will be redirected to the dashboard.');
      router.push('/dashboard');
    }
  })
  .subscribe()

B. Room State Subscriptions

Monitors changes to the room state:

The game's heartbeat is its state—lobby, answering, voting, results. When that state changes, everyone needs to know immediately. Room state subscriptions ensure that when the host clicks "Start Game," everyone's screen transforms in unison. It's the digital equivalent of saying "Ready, set, go!" and having everyone start at once.

const roomSubscription = supabase
  .channel(`room-state:${roomCode}`)
  .on('postgres_changes', {
    event: 'UPDATE',
    schema: 'public',
    table: 'rooms',
    filter: `id=eq.${room?.id}`
  }, (payload) => {
    console.log('Room update received:', payload.new);
    // Get the previous room state before updating
    const previousState = room?.current_state;
    const previousRound = room?.current_round;

    // Update room state
    const updatedRoom = payload.new as Room;
    setRoom(updatedRoom);

    // Check if host has left
    if (updatedRoom.host_left && !currentPlayer?.is_host) {
      alert('The host has left the game. You will be redirected to the dashboard.');
      router.push('/dashboard');
      return;
    }

    // Update current prompt if it exists in the room data
    if (updatedRoom.current_prompt_id && updatedRoom.current_prompt_text) {
      console.log('Updating current prompt:', updatedRoom.current_prompt_id, updatedRoom.current_prompt_text);
      setCurrentPrompt({
        id: updatedRoom.current_prompt_id,
        text: updatedRoom.current_prompt_text
      });
    }

    // Update current round if it exists in the room data and has changed
    if (updatedRoom.current_round && updatedRoom.current_round !== previousRound) {
      console.log(`Updating current round from ${previousRound || 1} to ${updatedRoom.current_round}`);
      setCurrentRound(updatedRoom.current_round);

      // For a new round, always reset player answer
      if (updatedRoom.current_round > (previousRound || 1)) {
        console.log('New round detected, resetting player answer');
        setPlayerAnswer('');
      }
    }

    // If the state changed to voting, load the answers for voting
    if (updatedRoom.current_state === 'voting' && previousState !== 'voting') {
      console.log('Room state changed to voting, loading answers');
      // Reset voting state when entering voting phase
      setHasVoted(false);
      setIsSubmittingVote(false);
      // Load answers after a short delay to ensure prompt is updated
      setTimeout(() => loadAnswersForVoting(), 500);
    }

    // If the state changed to answering (new round), reset player state
    if (updatedRoom.current_state === 'answering' && previousState !== 'answering') {
      console.log('Room state changed to answering, resetting player state');
      // Reset player answer for the new round
      setPlayerAnswer('');
      setHasVoted(false);
      setIsSubmittingVote(false);
      setAnswers([]);
    }
  })
  .subscribe()

C. Answer Subscriptions

Monitors new answers being submitted:

"Three players have answered, waiting for two more..." Answer subscriptions create that sense of collective progress as responses come in. They're the digital equivalent of seeing people put their cards down on the table—building anticipation without revealing what's written just yet.

const answersSubscription = supabase
  .channel(`answers:${roomCode}`)
  // Listen for database changes (requires real-time enabled for the answers table)
  .on('postgres_changes', {
    event: 'INSERT',
    schema: 'public',
    table: 'answers',
    filter: `room_id=eq.${room?.id}`
  }, (payload) => {
    console.log('New answer submitted (DB change):', payload);
    // When a new answer is submitted, check if all players have answered
    if (currentPlayer?.is_host) {
      console.log('Current player is host, checking if all players answered');
      checkAndUpdateGameState();
    } else {
      console.log('Current player is not host, skipping check');
    }
  })
  // Also listen for broadcast messages (works even if real-time is not enabled)
  .on('broadcast', { event: 'answer_submitted' }, (payload) => {
    console.log('New answer submitted (broadcast):', payload);
    if (currentPlayer?.is_host) {
      console.log('Current player is host, checking if all players answered');
      checkAndUpdateGameState();
    } else {
      console.log('Current player is not host, skipping check');
    }
  })
  .subscribe()

D. Vote Subscriptions

Monitors new votes being submitted:

The quiet satisfaction of seeing votes accumulate in real-time adds drama to the voting phase. Vote subscriptions track the invisible hands raising in support of different answers. They're the digital equivalent of watching poker chips pile up—each vote building towards the moment of truth.

const votesSubscription = supabase
  .channel(`votes:${roomCode}`)
  // Listen for database changes
  .on('postgres_changes', {
    event: 'INSERT',
    schema: 'public',
    table: 'votes',
    filter: `room_id=eq.${room?.id}`
  }, (payload) => {
    console.log('New vote submitted (DB change):', payload);
    // Refresh answers with votes
    if (room?.current_state === 'voting' || room?.current_state === 'results') {
      loadAnswersForVoting();
    }

    // If host, check if all players have voted
    if (currentPlayer?.is_host && room?.current_state === 'voting') {
      checkIfAllPlayersVoted();
    }
  })
  // Also listen for broadcast messages
  .on('broadcast', { event: 'vote' }, (payload) => {
    console.log('New vote submitted (broadcast):', payload);
    // Refresh answers with votes
    if (room?.current_state === 'voting' || room?.current_state === 'results') {
      loadAnswersForVoting();
    }

    // If host, check if all players have voted
    if (currentPlayer?.is_host && room?.current_state === 'voting') {
      checkIfAllPlayersVoted();
    }
  })
  .subscribe()

5. Score Tracking

The game tracks scores based on votes received:

const updatePlayerScores = () => {
  if (!answers || answers.length === 0) return;

  // Sort answers by vote count (highest first)
  const sortedAnswers = [...answers].sort((a, b) => b.votes - a.votes);

  // Award points: 3 points for 1st place, 2 for 2nd, 1 for 3rd
  const newScores = {...playerScores};

  if (sortedAnswers[0] && sortedAnswers[0].player_id) {
    newScores[sortedAnswers[0].player_id] = (newScores[sortedAnswers[0].player_id] || 0) + 3;
  }

  if (sortedAnswers[1] && sortedAnswers[1].player_id) {
    newScores[sortedAnswers[1].player_id] = (newScores[sortedAnswers[1].player_id] || 0) + 2;
  }

  if (sortedAnswers[2] && sortedAnswers[2].player_id) {
    newScores[sortedAnswers[2].player_id] = (newScores[sortedAnswers[2].player_id] || 0) + 1;
  }

  setPlayerScores(newScores);
};

6. Final Results

At the end of all rounds, the final results are displayed:

The grand finale—where points from all rounds combine to crown the ultimate champion of wit. The final results screen needed to feel celebratory, with a touch of friendly rivalry. It's the digital equivalent of that moment when the board game ends and everyone counts their points—some gloating, some plotting revenge in the next game.

{showFinalResults ? (
  <div className="flex h-full flex-col items-center justify-center">
    <h3 className="mb-4 text-2xl font-bold">🏆 Final Results 🏆</h3>
    <p className="mb-6 text-center">
      Game complete! Here are the final scores after {MAX_ROUNDS} rounds.
    </p>

    <div className="w-full max-w-2xl space-y-4">
      {Object.entries(playerScores)
        .sort(([, scoreA], [, scoreB]) => scoreB - scoreA)
        .map(([playerId, score], index) => {
          const player = players.find(p => p.id === playerId);
          return (
            <div 
              key={playerId} 
              className={`rounded-lg border p-4 ${index === 0 ? 'border-yellow-400 bg-yellow-50' : 'border-gray-200'}`}
            >
              <div className="flex items-center justify-between">
                <div className="flex items-center space-x-3">
                  <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 font-bold">
                    {index + 1}
                  </div>
                  <p className="text-lg font-medium">
                    {player?.nickname || 'Unknown Player'}
                    {player?.id === currentPlayer?.id && <span className="ml-2 text-blue-500">(You)</span>}
                  </p>
                  {index === 0 && <span className="ml-2 text-xl">👑</span>}
                </div>
                <div className="text-xl font-bold">{score} points</div>
              </div>
            </div>
          );
        })}
    </div>
  </div>
) : null}

Conclusion: The Real Magic of Supabox TV

While we've meticulously documented the game flow implementation with its state machines, round tracking, and real-time subscriptions (yawn, how technical), the real magic of Supabox TV isn't in the code—it's in the chaos that unfolds when friends gather in virtual living rooms! Sure, we've built a robust system that carefully tracks who answered what and tallies votes with mathematical precision, but that's just the digital plumbing behind the scenes.

What truly matters is that moment when your friend's terrible joke gets zero votes and they silently question all their life choices, or when your perfectly crafted witty response is completely misunderstood by everyone.

We've created a digital arena where friendships are tested, egos are bruised, and the person you least expect somehow becomes the comedy champion with a crown emoji next to their name. The real achievement isn't our elegant state management system—it's the fact that we've built a platform where people can collectively groan at dad jokes and passive-aggressively vote for the answer that makes fun of the host.

So while you now understand how our game technically works, remember that the true engineering feat is creating a space where friends can be their most ridiculous selves while a database quietly keeps score.

This project was built with Supabase, Next.js and Shadcn UI components. The neobrutalism styling gives it a playful, modern look that matches the fun nature of the game.

0
Subscribe to my newsletter

Read articles from himu directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

himu
himu