Building a multiplayer UNO card game

developmentreacttypescriptsupabasemultiplayergame-engine

UNO — Feature Tutorial

A walkthrough of how the multiplayer UNO card game was built, why each decision was made, and how you could replicate it yourself.


Table of Contents

  1. What is this feature?
  2. Tech stack at a glance
  3. File structure
  4. How the pages are wired up (Next.js routing)
  5. Card and game types (types.ts)
  6. Building a 108-card deck (deck.ts)
  7. Fisher-Yates shuffle and counter-based IDs
  8. Pure query functions (helpers.ts)
  9. The game state machine (actions.ts)
  10. Direction as ±1 and modulo arithmetic
  11. Wild cards and the "effective card" concept
  12. The UNO announce/challenge vulnerability window
  13. Auto-play on draw and deck reshuffling
  14. Supabase Realtime multiplayer (multiplayer.ts)
  15. Unique channel names and subscription lifecycle
  16. Optimistic local updates
  17. The three-phase component pattern
  18. The game UI: hands, piles, and the color picker
  19. Card assets and SVG mapping (card-image.ts)
  20. Internationalisation (i18n)
  21. Player names

1. What is this feature?

UNO is a classic card game where 2–4 players take turns matching cards by colour or number, using action cards (skip, reverse, draw 2) and wild cards to disrupt opponents. The first player to empty their hand wins.

This implementation is a fully playable, real-time multiplayer React component. Players create or join rooms with a 6-character code, and all game state is synchronised through Supabase Realtime. The game engine runs entirely in the browser as pure functions — think of it as a referee that only knows the rules: it has no idea about the internet, the screen, or the players' names. It takes a state in, applies a move, and hands a new state back.

It was ported from a standalone Express/MongoDB project multiplayer-uno to work as a static site with Supabase, and built as a learning exercise covering immutable state management, real-time multiplayer, and N-player lobby systems.


2. Tech stack at a glance

TechnologyRole in this featureLearn more
React 18UI component model, hooks for state and effects📖 react.dev
TypeScriptStatic typing for cards, game state, and props📖 typescriptlang.org
Next.js 14File-based routing, server components, metadata📖 nextjs.org/docs
Tailwind CSSUtility-first styling, responsive sizes, dark mode📖 tailwindcss.com/docs
clsxConditionally joining class-name strings📖 github: lukeed/clsx
Supabase RealtimePostgreSQL database + WebSocket broadcast for multiplayer📖 supabase.com/docs/realtime
MDXWriting app descriptions in Markdown with JS exports📖 mdxjs.com

3. File structure

src/
├── lib/
│   ├── uno-engine/                 ← Pure game logic (no React, no network)
│   │   ├── types.ts                ← Card & game-state type definitions
│   │   ├── deck.ts                 ← Deck generation + Fisher-Yates shuffle
│   │   ├── helpers.ts              ← Read-only query functions
│   │   ├── actions.ts              ← State-machine transitions (throw, draw…)
│   │   ├── card-image.ts           ← SVG filename mapping + colour classes
│   │   └── index.ts                ← Barrel export (public API)
│   │
│   ├── multiplayer.ts              ← Supabase room CRUD + Realtime subscriptions
│   └── supabase.ts                 ← Supabase client singleton
│
├── components/
│   └── multiplayer/
│       ├── MultiplayerUno.tsx      ← Main game component (lobby → waiting → game)
│       ├── GameLobby.tsx           ← Reusable create/join lobby
│       └── WaitingRoom.tsx         ← Reusable waiting room with room code
│
├── app/
│   └── apps/
│       └── uno/                    ← URL: /apps/uno/
│           ├── page.tsx            ← Info/detail page (server component)
│           ├── page.en.mdx         ← App metadata + English description
│           ├── page.de.mdx         ← German description
│           ├── MdxContent.tsx      ← Language switcher for MDX
│           └── play/
│               └── multiplayer/
│                   └── page.tsx    ← Game page (server component)
│
├── i18n/
│   └── translations/
│       ├── en.ts                   ← English strings (uno.* keys)
│       └── de.ts                   ← German strings (uno.* keys)
│
public/
└── uno-cards/                      ← 65 SVG card images + back.jpeg

The key organisational principle: the engine (uno-engine/) is completely isolated from React and Supabase. It exports pure functions and types. The multiplayer layer (multiplayer.ts) handles Supabase rooms and subscriptions. The component (MultiplayerUno.tsx) ties them together. This separation means you could swap Supabase for Firebase, or React for Svelte, without touching the game rules.


4. How the pages are wired up

There are two distinct pages for this feature.

The info page (page.tsx)

// src/app/apps/uno/page.tsx
import { AppDetailLayout } from '@/components/AppDetailLayout'
import { MdxContent } from './MdxContent'
import { app as baseApp } from './page.en.mdx'

const app = { ...baseApp, slug: 'uno' }

export default async function UnoPage() {
  return (
    <AppDetailLayout app={app}>
      <MdxContent />
    </AppDetailLayout>
  )
}

page.en.mdx exports a JavaScript object (app) containing the title, description, tech tags, and date. The layout component handles the card, the "Go to app" button, and related journal entries.

The play page (play/multiplayer/page.tsx)

// src/app/apps/uno/play/multiplayer/page.tsx
export default function MultiplayerUnoPage() {
  return (
    <Container className="mt-16 sm:mt-32">
      <MultiplayerPageHeader
        titleKey="mp.uno.title"
        descriptionKey="mp.uno.description"
      />
      <div className="mt-16 sm:mt-20">
        <MultiplayerUno />
      </div>
    </Container>
  )
}

This is a server component — it generates HTML on the server and sends it to the browser. It contains no game logic; it just wraps shared layout components around <MultiplayerUno />, which is a client component (marked with 'use client'). All interactivity lives inside the client component.

📖 Next.js — Server vs Client Components


5. Card and game types (types.ts)

Every concept in the game is named before it is used — like a rulebook's glossary.

// src/lib/uno-engine/types.ts
export type CardType  = 'number' | 'special' | 'wild'
export type CardColor = 'red' | 'blue' | 'green' | 'yellow' | 'wild'
export type CardValue = SpecialCardName | CardNumber

export interface UNOCard {
  type: CardType
  color: CardColor
  value: CardValue
  id: string       // unique per physical card instance
}

CardType distinguishes number cards (0–9), special cards (skip, reverse, draw2), and wild cards (colour change, draw 4). CardColor includes the four suit colours plus 'wild' for uncoloured cards. The id is a unique string for every physical card in the deck — even two "Red 5" cards have different IDs. This matters for React's key prop and for finding a specific card in a player's hand.

The game state

export interface UNOGameState {
  cardDeck: UNOCard[]                    // draw pile
  thrownCards: UNOCard[]                 // discard pile
  playerHands: Record<string, UNOCard[]> // each player's hand
  playerOrder: string[]                  // ordered player IDs
  currentPlayerIndex: number             // whose turn it is
  lastThrownCard: UNOCard | null         // card on top of discard
  direction: 1 | -1                      // clockwise or counter
  status: GameStatus                     // 'WAITING' | 'STARTED' | 'FINISHED'
  runningEvents: {
    vulnerableToUNO: string | null       // forgot to announce UNO
    hasAnnouncedUNO: string | null       // just announced UNO
  }
}

direction: 1 | -1 is a typed literal union — TypeScript ensures only 1 or -1 can ever be assigned. This elegantly handles turn order via modulo arithmetic (explained in Section 10).

📖 TypeScript — Literal Types


6. Building a 108-card deck (deck.ts)

A standard UNO deck has exactly 108 cards:

CategoryCards per colourColoursTotal
Number '0'144
Numbers '1'–'9'2 each (18)472
Skip, Reverse, Draw 22 each (6)424
Colour Change (wild)4
Wild Draw 44
Total108
// src/lib/uno-engine/deck.ts
export function getShuffledCardDeck(): UNOCard[] {
  const deck: UNOCard[] = []
  const counter: Record<string, number> = {}

  for (const color of COLORS) {
    if (color === 'wild') {
      for (const value of WILD_VALUES) {
        for (let i = 0; i < 4; i++) {
          deck.push(makeCard('wild', color, value, counter))
        }
      }
    } else {
      for (const value of NUM_VALUES) {
        deck.push(makeCard('number', color, value, counter))
        if (value !== '0') {
          deck.push(makeCard('number', color, value, counter))
        }
      }
      for (const value of SPECIAL_VALUES) {
        deck.push(makeCard('special', color, value, counter))
        deck.push(makeCard('special', color, value, counter))
      }
    }
  }

  shuffle(deck)
  return deck
}

The nested loops mirror the table above. The if (value !== '0') guard ensures there is only one zero card per colour (a real UNO rule that is easy to overlook). After building the array, the deck is shuffled in-place before returning.

📖 UNO official rules


7. Fisher-Yates shuffle and counter-based IDs

Fisher-Yates shuffle

export function shuffle<T>(arr: T[]): T[] {
  for (let i = arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * i)
    ;[arr[i], arr[j]] = [arr[j], arr[i]]
  }
  return arr
}

This walks backwards through the array. At each position it picks a random index from 0 to i - 1 and swaps. The result is a perfectly uniform random permutation — every arrangement is equally likely. Think of it like shuffling a deck of real cards by repeatedly pulling a card from a random spot and placing it at the back of the pile.

This is the same algorithm used in the 15-puzzle feature, but there it also needed a solvability check. UNO has no unsolvable states — every shuffle is playable.

📖 Wikipedia — Fisher-Yates shuffle

Counter-based unique IDs

function makeCard(
  type: CardType,
  color: CardColor,
  value: CardValue,
  counter: Record<string, number>,
): UNOCard {
  const baseId = `card-${type}-${color}-${value}`
  counter[baseId] = (counter[baseId] ?? 0) + 1
  return { type, color, value, id: `${baseId}-${counter[baseId]}` }
}

A UNO deck has duplicate cards — two Red 5s, two Blue Skips, etc. They look the same but need distinct identities. The counter object tracks how many of each card have been created, appending a sequence number: card-number-red-5-1, card-number-red-5-2. This lets React use the id as a stable key prop, and lets the engine find the exact card a player clicked.


8. Pure query functions (helpers.ts)

These three functions are read-only — they inspect the game state but never modify it. Think of them as asking a referee: "Whose turn is it?" or "Can I play this card?"

// src/lib/uno-engine/helpers.ts
export function getCurrentPlayerId(state: UNOGameState): string {
  return state.playerOrder[state.currentPlayerIndex]
}

export function getThrowableCards(
  state: UNOGameState,
  playerId: string,
): UNOCard[] {
  const hand = state.playerHands[playerId] ?? []
  const { lastThrownCard } = state

  return hand.filter(
    (card) =>
      !lastThrownCard ||              // no card on pile → anything goes
      card.color === lastThrownCard.color ||  // same colour
      card.value === lastThrownCard.value ||  // same number/action
      card.type === 'wild',                   // wild always playable
  )
}

The matching rule is the heart of UNO: a card is playable if it shares a colour or value with the top of the discard pile, or if it is a wild card. The !lastThrownCard case handles the very first turn when no card has been played yet — everything is valid.

canThrowCard combines the turn check and the throwability check into a single convenience function that the game UI calls before every move.

📖 MDN — Array.prototype.filter


9. The game state machine (actions.ts)

Every game action is a pure function: it takes the current state, applies a rule, and returns a new state object. The old state is never mutated. This is like chess notation — each move takes the current board position and produces a new one, without erasing the old.

Initialising a game

export function initGame(playerIds: string[]): UNOGameState {
  const deck = getShuffledCardDeck()
  const playerHands: Record<string, UNOCard[]> = {}

  for (const id of playerIds) {
    playerHands[id] = deck.splice(0, NUM_CARDS_PER_PLAYER) // deal 7 each
  }

  // Find first non-wild card for the initial discard
  let firstCardIndex = deck.findIndex((c) => c.type !== 'wild')
  if (firstCardIndex === -1) firstCardIndex = 0
  const [firstCard] = deck.splice(firstCardIndex, 1)

  return {
    cardDeck: deck,
    thrownCards: [],
    playerHands,
    playerOrder: playerIds,
    currentPlayerIndex: 0,
    lastThrownCard: firstCard,
    direction: 1,
    status: 'STARTED',
    runningEvents: { vulnerableToUNO: null, hasAnnouncedUNO: null },
  }
}

Each player gets 7 cards via splice(0, 7) — which both removes the cards from the deck and returns them. The first non-wild card is placed face-up on the discard pile. Wild cards are skipped because starting with a wild would require an immediate colour choice with no context.

Playing a card

export function throwCard(
  state: UNOGameState,
  playerId: string,
  cardId: string,
  chosenColor?: CardColor,
): UNOGameState {
  if (!canThrowCard(state, playerId, cardId)) return state  // guard

  const hand = state.playerHands[playerId]
  const card = hand.find((c) => c.id === cardId)
  if (!card) return state

  const newHand = hand.filter((c) => c.id !== cardId)       // remove card

  // Wild cards get colour-overridden on the discard pile
  const effectiveCard: UNOCard =
    card.type === 'wild' && chosenColor
      ? { ...card, color: chosenColor }
      : card

  let newState: UNOGameState = {
    ...state,
    playerHands: { ...state.playerHands, [playerId]: newHand },
    thrownCards: [...state.thrownCards, card],
    lastThrownCard: effectiveCard,
    // ... (UNO vulnerability + win check + card effects + next turn)
  }
  // ...
}

The first line is a guard clause — it exits early if the move is illegal, returning the unchanged state. This means callers never need to worry about error handling; they can just compare the returned state to the original to check if the move succeeded.

The [...board] spread creates immutable updates — React requires state to be replaced, not mutated. The engine applies this principle throughout: every spread, filter, or splice operates on copies.

📖 React — Updating arrays in state


10. Direction as ±1 and modulo arithmetic

Turn order is controlled by a single number: direction, which is either 1 (clockwise) or -1 (counter-clockwise).

function nextPlayerIndex(state: UNOGameState): number {
  const len = state.playerOrder.length
  return (len + state.currentPlayerIndex + state.direction) % len
}

This is modular arithmetic — the % len wraps around so that going past the last player loops back to the first, and going before the first loops to the last. The + len before the modulo is critical: JavaScript's % operator can return negative numbers (e.g. -1 % 4 is -1, not 3). Adding len first guarantees a positive result.

Analogy: Imagine four people sitting around a table, numbered 0–3. "Clockwise" means add 1 to the current seat. "Counter-clockwise" means subtract 1. When you reach the end, wrap around. That's all this formula does.

How Reverse and Skip use this

case 'reverse': {
  const newDirection = (state.direction * -1) as 1 | -1
  // In 2-player, reverse acts as skip
  if (len === 2) {
    return {
      ...state,
      direction: newDirection,
      currentPlayerIndex: nextPlayerIndex(
        { ...state, direction: newDirection }
      ),
    }
  }
  return { ...state, direction: newDirection }
}

case 'skip':
  // Advance once extra (throwCard will advance again)
  return { ...state, currentPlayerIndex: nextPlayerIndex(state) }

Reverse flips the direction by multiplying by -1. In a 2-player game, reversing direction still points at the other player, so it also advances the turn — effectively acting as a skip. Skip advances the currentPlayerIndex one extra step inside handleCardEffect; then throwCard advances once more, skipping the next player entirely.

📖 MDN — Remainder operator (%)


11. Wild cards and the "effective card" concept

When a player plays a wild card, they choose a colour. But the card itself is colourless — it has color: 'wild'. The engine resolves this with the effective card concept:

const effectiveCard: UNOCard =
  card.type === 'wild' && chosenColor
    ? { ...card, color: chosenColor }  // override colour
    : card

The original card (with color: 'wild') goes into thrownCards (the discard pile array). But lastThrownCard stores the colour-overridden version. This means the next player's matching check sees the chosen colour, not 'wild'.

The UI reads lastThrownCard.color to draw a coloured ring around the discard pile:

const lastCardColor = state.lastThrownCard?.color ?? 'wild'
const colorBorderClass =
  lastCardColor === 'red' ? 'ring-red-500'
  : lastCardColor === 'blue' ? 'ring-blue-500'
  : /* ... other colours */

This means the discard pile always shows what colour the next player must match — even after a wild card.


12. The UNO announce/challenge vulnerability window

In real UNO, you must shout "UNO!" when you are down to one card. If you forget and someone catches you, you draw 2 penalty cards. The engine models this with a two-field state machine:

runningEvents: {
  vulnerableToUNO: string | null,  // player who forgot to shout
  hasAnnouncedUNO: string | null,  // player who just shouted
}

The flow

  1. Player plays a card and goes down to 1 card. If they have not called announceUno beforehand, the engine sets vulnerableToUNO = playerId.
  2. Any opponent can now call challengeUno. If the target is indeed vulnerable, they draw 2 penalty cards.
  3. If the player called announceUno before playing, then hasAnnouncedUNO is set and vulnerableToUNO stays null — no challenge is possible.
// In throwCard, after removing the card from hand:
if (newHand.length === 1 &&
    state.runningEvents.hasAnnouncedUNO !== playerId) {
  newState.runningEvents.vulnerableToUNO = playerId
}
export function challengeUno(
  state: UNOGameState,
  _challengerId: string,
  targetId: string,
): UNOGameState {
  if (state.runningEvents.vulnerableToUNO !== targetId) return state
  return drawCards(state, targetId, 2)  // 2 penalty cards
}

The UI shows a pulsing "UNO!" button when you can announce, and a red "Challenge!" button when an opponent is vulnerable.


13. Auto-play on draw and deck reshuffling

When a player has no playable cards, they draw from the deck. The engine handles several cases in drawCardAction:

export function drawCardAction(
  state: UNOGameState,
  playerId: string,
): UNOGameState {
  let newState = drawCards(state, playerId, 1)
  const hand = newState.playerHands[playerId]
  const drawnCard = hand[hand.length - 1]  // last card = drawn card

  const throwable = getThrowableCards(newState, playerId)
  const canAutoPlay = throwable.some((c) => c.id === drawnCard.id)

  if (canAutoPlay && drawnCard.type !== 'wild') {
    return throwCard(newState, playerId, drawnCard.id)  // auto-play!
  }

  if (!canAutoPlay) {
    return { ...newState, currentPlayerIndex: nextPlayerIndex(newState) }
  }

  // Playable wild → return state, let UI show colour picker
  return newState
}

Three outcomes:

  1. Non-wild playable card → automatically played (better UX, keeps the game flowing).
  2. Wild playable card → state returned as-is; the UI shows the colour picker so the player can choose.
  3. Not playable → turn advances to the next player.

Deck reshuffling

When the draw pile runs out, the engine reshuffles the discard pile back in:

function drawCards(state, playerId, numCards) {
  let deck = [...state.cardDeck]
  let thrown = [...state.thrownCards]

  if (deck.length < numCards) {
    deck = [...deck, ...shuffle([...thrown])]
    thrown = []
  }

  const drawn = deck.splice(-numCards, numCards)
  // ...
}

The discard pile is shuffled and appended to the remaining deck. This mirrors real UNO rules — when the draw pile is empty, you flip and shuffle the discard pile to continue.


14. Supabase Realtime multiplayer (multiplayer.ts)

The multiplayer layer bridges the pure game engine and the network. It stores game state in a Supabase PostgreSQL table and broadcasts changes via WebSocket subscriptions.

The Room model

export interface Room {
  id: string
  code: string               // 6-char room code for sharing
  game_type: 'tic-tac-toe' | 'memory-game' | 'uno'
  status: 'waiting' | 'playing' | 'finished'
  players: string[]           // ordered player IDs
  host_player: string
  max_players: number
  game_state: UNOGameState    // the full engine state as JSON
  current_turn: string | null
  winner: string | null
}

Analogy: Supabase acts like a shared whiteboard in a classroom. Every player writes their move on it, and everyone else sees the update instantly via Realtime.

Room codes

function generateRoomCode(): string {
  const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
  let code = ''
  for (let i = 0; i < 6; i++) {
    code += chars[Math.floor(Math.random() * chars.length)]
  }
  return code
}

The character set deliberately excludes I, L, O, 0, and 1 — characters that look alike and cause typos when shared verbally. 6 characters from 30 options gives 729 million possible codes.

createRoom retries up to 3 times on duplicate key errors (Postgres error code 23505), making collisions graceful.

Persisting game state

export async function updateGameState(
  roomId: string,
  gameState: UNOGameState,
  currentTurn: string | null,
  winner?: string | null,
  status?: Room['status'],
): Promise<void> {
  // ...
  await supabase.from('rooms').update(update).eq('id', roomId)
}

After every game action, the component calls updateGameState to push the new state to Supabase. Other players receive it via their Realtime subscription and re-render.

📖 Supabase — Realtime


15. Unique channel names and subscription lifecycle

A subtle but important fix: Supabase Realtime channels are identified by name. If two components subscribe with the same channel name and one unsubscribes, it kills the other's subscription too.

This caused a real bug: WaitingRoom subscribes to the room, then when the game starts, WaitingRoom unmounts and unsubscribes — accidentally destroying MultiplayerUno's subscription.

The fix: a module-level counter

// src/lib/multiplayer.ts
let channelCounter = 0

export function subscribeToRoom(
  roomId: string,
  onUpdate: (room: Room) => void,
): RealtimeChannel {
  const channel = supabase
    .channel(`room:${roomId}:${++channelCounter}`)
    // ...
}

Every call to subscribeToRoom gets a unique channel name: room:abc123:1, room:abc123:2, etc. Now WaitingRoom's cleanup removes channel :1 without affecting MultiplayerUno's channel :2.

Analogy: Think of it like giving each listener their own radio frequency instead of sharing one. Turning off your radio doesn't silence everyone else's.

The subscription effect

useEffect(() => {
  if (!room) return
  channelRef.current = subscribeToRoom(room.id, handleRoomUpdate)

  return () => {
    if (channelRef.current) {
      unsubscribeFromRoom(channelRef.current)
      channelRef.current = null
    }
  }
}, [room?.id, handleRoomUpdate])

The useRef stores the channel reference across renders without causing re-renders. The cleanup function in the effect's return ensures the subscription is removed when the component unmounts or when the room changes — preventing memory leaks and ghost listeners.

📖 React — useEffect cleanup


16. Optimistic local updates

Without optimisation, clicking a card would follow this path:

  1. Compute new state locally
  2. Send to Supabase (network round-trip)
  3. Supabase broadcasts the update
  4. Receive the broadcast and update UI

That could take 200–500ms — enough to feel sluggish. The fix is an optimistic update: update the UI immediately, then persist to Supabase in the background.

async function applyNewState(newState: UNOGameState) {
  if (!room) return

  const nextTurn = newState.status === 'FINISHED'
    ? null
    : getCurrentPlayerId(newState)
  const winnerId = newState.status === 'FINISHED'
    ? newState.playerOrder.find(
        (pid) => (newState.playerHands[pid]?.length ?? 0) === 0
      ) ?? null
    : null

  // Optimistic local update — UI responds instantly
  setRoom({
    ...room,
    game_state: newState,
    current_turn: nextTurn,
    winner: winnerId,
    status: newState.status === 'FINISHED' ? 'finished' : room.status,
  })

  // Persist to Supabase (other players receive via Realtime)
  try {
    await updateGameState(
      room.id, newState, nextTurn, winnerId,
      newState.status === 'FINISHED' ? 'finished' : undefined,
    )
  } catch (err) {
    console.error('[UNO] updateGameState failed:', err)
  }
}

setRoom(...) fires before the await, so React re-renders immediately with the new state. The Supabase write happens in the background. Other players still receive the update via their Realtime subscription — only the acting player sees the instant response.

This pattern is sometimes called optimistic UI — you assume the write will succeed and show the result immediately. If it fails (the catch block), the local state may be stale, but in practice game moves rarely fail because they are validated by the pure engine before being sent.

📖 React — Optimistic Updates


17. The three-phase component pattern

MultiplayerUno manages three distinct phases with simple conditional returns:

// Phase 1: No room yet → show lobby
if (!room) {
  return (
    <GameLobby
      gameType="uno"
      maxPlayers={4}
      initialGameState={() => ({} as UNOGameState)}
      onRoomReady={handleRoomReady}
    />
  )
}

// Phase 2: Room exists but waiting → show waiting room
if (room.status === 'waiting') {
  return (
    <WaitingRoom
      room={room}
      playerId={playerId}
      onRoomReady={(r) => setRoom(r)}
      onStartGame={handleStartGame}
    />
  )
}

// Phase 3: Game in progress → render game UI
const state = room.game_state as UNOGameState
// ...full game rendering...

This pattern is used identically across all three multiplayer games (UNO, Tic-Tac-Toe, Memory). The difference is only in Phase 3 — each game has its own rendering logic.

GameLobby (reusable)

GameLobby handles creating and joining rooms. It offers two modes: Create (generates a room and shows the code) or Join (enter a 6-character code). For 2-player games, it auto-starts when the second player joins. For N-player games like UNO (2–4 players), it delegates to the WaitingRoom phase.

WaitingRoom (reusable)

WaitingRoom shows the room code (with a copy button), a list of connected players, and a Start Game button visible only to the host. It subscribes to Realtime updates so that when the host starts the game, all connected clients transition to Phase 3 simultaneously.


18. The game UI: hands, piles, and the color picker

The game phase renders five visual zones stacked vertically:

Status bar

<div className={clsx(
  'rounded-lg px-4 py-2 text-lg font-medium',
  isGameOver
    ? winnerId === playerId
      ? 'bg-green-50 text-green-700 ...'   // you won
      : 'bg-red-50 text-red-700 ...'       // you lost
    : isMyTurn
      ? 'bg-violet-50 text-violet-700 ...' // your turn
      : 'bg-zinc-100 text-zinc-600 ...',   // opponent's turn
)}>
  {statusText}
</div>

Opponents

Each opponent is shown as a card with their player number, host indicator (★), face-down cards (using CARD_BACK_SRC), and a card count. The active player's card gets a violet border highlight.

Centre: draw pile + discard pile + direction

The draw pile is a <button> (disabled when it's not your turn). The discard pile shows the last thrown card with a coloured ring that reflects the effective colour — important after wild cards. A direction indicator shows ↻ (clockwise) or ↺ (counter-clockwise).

Action buttons

The pulsing "UNO!" button (animate-pulse) appears when you have 1–2 cards and can announce. The red "Challenge!" button appears when an opponent is vulnerable.

Your hand

{myHand.map((card) => {
  const isPlayable = isMyTurn && throwableIds.has(card.id)
  return (
    <button
      key={card.id}
      onClick={() => handleCardClick(card.id)}
      disabled={!isPlayable || isGameOver}
      className={clsx(
        'rounded-lg transition-all',
        isPlayable
          ? 'cursor-pointer ring-2 ring-violet-400 hover:-translate-y-2 hover:scale-105'
          : 'cursor-default opacity-70',
      )}
    >
      <Image src={getCardImageSrc(card)} ... />
    </button>
  )
})}

Playable cards get a violet ring and a hover lift effect (hover:-translate-y-2 hover:scale-105). Non-playable cards are dimmed at 70% opacity. The throwableIds is a Set created from getThrowableCards for O(1) lookup — much faster than calling Array.includes on every card during render.

The wild card colour picker

When a player clicks a wild card, pendingWildCardId is set instead of immediately playing it. This triggers a full-screen overlay with four coloured buttons (red, blue, green, yellow). Selecting a colour calls throwCard(state, playerId, cardId, chosenColor) to play the card with the chosen colour.

{pendingWildCardId && (
  <ColorPicker
    onSelect={handleWildColorSelect}
    onCancel={() => setPendingWildCardId(null)}
  />
)}

📖 Tailwind CSS — Hover, Focus, and Active states


19. Card assets and SVG mapping (card-image.ts)

The public/uno-cards/ directory contains 65 SVG files following a naming convention:

CardFilenameExample
Number{prefix}{number}.svgRed 5 → r5.svg
Skip{prefix}r.svgBlue Skip → br.svg
Reverse{prefix}x.svgGreen Reverse → gx.svg
Draw 2{prefix}p2.svgYellow Draw 2 → op2.svg
Colour ChangeCC.svg
Wild Draw 4P4.svg
Card backback.jpeg

The colour prefix mapping has a quirk — yellow uses 'o' (for orange, inherited from the SVG asset source):

const COLOR_PREFIX: Record<string, string> = {
  red: 'r', blue: 'b', green: 'g', yellow: 'o',
}

getCardImageSrc maps any UNOCard to its SVG path:

export function getCardImageSrc(card: UNOCard): string {
  if (card.type === 'wild') {
    return card.value === 'draw4'
      ? '/uno-cards/P4.svg'
      : '/uno-cards/CC.svg'
  }
  const prefix = COLOR_PREFIX[card.color] ?? 'r'
  switch (card.value) {
    case 'skip':    return `/uno-cards/${prefix}r.svg`
    case 'reverse': return `/uno-cards/${prefix}x.svg`
    case 'draw2':   return `/uno-cards/${prefix}p2.svg`
    default:        return `/uno-cards/${prefix}${card.value}.svg`
  }
}

CARD_BACK_SRC is used for opponents' hidden cards and the draw pile.


20. Internationalisation (i18n)

All user-visible strings are stored in translation files rather than hardcoded in the component:

// src/i18n/translations/en.ts
'uno.title': 'UNO',
'uno.description': 'The classic card game. ...',
'uno.yourTurn': 'Your turn',
'uno.opponentsTurn': "{{name}}'s turn…",
'uno.drawCard': 'Draw',
'uno.announceUno': 'UNO!',
'uno.challengeUno': 'Challenge!',
'uno.chooseColor': 'Choose a color',
'uno.youWin': 'You win! 🎉',
'uno.playerWins': '{{name}} wins!',
'uno.cardsLeft': '{{count}} cards',

The {{name}}, {{count}} placeholders are replaced at runtime by the t() function:

const { t } = useTranslation()
t('uno.opponentsTurn', { name: 'P2' })  // → "P2's turn…"
t('uno.cardsLeft', { count: 3 })        // → "3 cards"

The same system provides German translations in de.ts with identical keys. The MDX content switcher (MdxContent.tsx) renders the German or English description based on the locale.

📖 React — Context for theming and i18n


21. Player names

Updated 2026-04-07

By default, players were shown as P1, P2, P3, P4 — functional but impersonal. We added an optional name input so players can identify themselves across all three multiplayer games (UNO, Tic-Tac-Toe, Memory).

Where the name lives

The name is stored in two places:

  1. localStorage — so the input pre-fills next time you play.
  2. A player_names JSONB column on the Supabase rooms table — so other players can see your name in real time.
// src/lib/player.ts
const PLAYER_NAME_KEY = 'multiplayer_player_name'

export function getPlayerName(): string {
  if (typeof window === 'undefined') return ''
  return localStorage.getItem(PLAYER_NAME_KEY) ?? ''
}

export function setPlayerName(name: string): void {
  if (typeof window === 'undefined') return
  localStorage.setItem(PLAYER_NAME_KEY, name)
}

The Room interface gained a new field:

// src/lib/multiplayer.ts
export interface Room {
  // ...existing fields...
  player_names: Record<string, string>  // playerId → display name
}

Collecting the name in the lobby

GameLobby shows a text input above the Create/Join buttons. The name is limited to 20 characters and saved to localStorage when the player creates or joins a room:

// src/components/multiplayer/GameLobby.tsx
<input
  type="text"
  value={name}
  onChange={(e) => setName(e.target.value.slice(0, 20))}
  placeholder={t('mp.enterName')}
  maxLength={20}
  className="w-full rounded-lg border ..."
/>

Both createRoom and joinRoom now accept an optional playerName parameter and store it in the player_names JSONB column. When joining, the new name is merged into the existing map so all players' names coexist:

const updatedNames = {
  ...(room.player_names ?? {}),
  ...(playerName ? { [playerId]: playerName } : {}),
}

Displaying names in the game

Each game component resolves a player ID to a display name with a simple helper that falls back to the positional label:

// src/components/multiplayer/MultiplayerUno.tsx
const names = room.player_names ?? {}

function displayName(pid: string): string {
  return names[pid] || `P${state.playerOrder.indexOf(pid) + 1}`
}

This helper is used everywhere a player is referenced: the status bar ("Alice's turn..."), the opponent cards, and the "Your hand" label. The WaitingRoom also shows names in its player list pills.

For the 2-player games (Tic-Tac-Toe and Memory), the pattern is similar but uses direct variables instead of a helper:

const myName = names[playerId]
  || t('mp.you').replace(':', '')
const opponentName = opponentId
  ? (names[opponentId] || t('mp.opponent').replace(':', ''))
  : t('mp.opponent').replace(':', '')

Database migration

Since there is no migration system, the column must be added manually in Supabase:

ALTER TABLE rooms
  ADD COLUMN player_names jsonb DEFAULT '{}'::jsonb;

The ?? {} fallback in the code ensures backward compatibility with rooms created before the column existed.

📖 Supabase — Database columns


Made with React, TypeScript, Next.js, Tailwind CSS, and Supabase Realtime. Ask for any section to be expanded or a specific function to be explained in more detail.