Building a multiplayer UNO card game
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
- What is this feature?
- Tech stack at a glance
- File structure
- How the pages are wired up (Next.js routing)
- Card and game types (types.ts)
- Building a 108-card deck (deck.ts)
- Fisher-Yates shuffle and counter-based IDs
- Pure query functions (helpers.ts)
- The game state machine (actions.ts)
- Direction as ±1 and modulo arithmetic
- Wild cards and the "effective card" concept
- The UNO announce/challenge vulnerability window
- Auto-play on draw and deck reshuffling
- Supabase Realtime multiplayer (multiplayer.ts)
- Unique channel names and subscription lifecycle
- Optimistic local updates
- The three-phase component pattern
- The game UI: hands, piles, and the color picker
- Card assets and SVG mapping (card-image.ts)
- Internationalisation (i18n)
- 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
| Technology | Role in this feature | Learn more |
|---|---|---|
| React 18 | UI component model, hooks for state and effects | 📖 react.dev |
| TypeScript | Static typing for cards, game state, and props | 📖 typescriptlang.org |
| Next.js 14 | File-based routing, server components, metadata | 📖 nextjs.org/docs |
| Tailwind CSS | Utility-first styling, responsive sizes, dark mode | 📖 tailwindcss.com/docs |
| clsx | Conditionally joining class-name strings | 📖 github: lukeed/clsx |
| Supabase Realtime | PostgreSQL database + WebSocket broadcast for multiplayer | 📖 supabase.com/docs/realtime |
| MDX | Writing 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).
6. Building a 108-card deck (deck.ts)
A standard UNO deck has exactly 108 cards:
| Category | Cards per colour | Colours | Total |
|---|---|---|---|
| Number '0' | 1 | 4 | 4 |
| Numbers '1'–'9' | 2 each (18) | 4 | 72 |
| Skip, Reverse, Draw 2 | 2 each (6) | 4 | 24 |
| Colour Change (wild) | — | — | 4 |
| Wild Draw 4 | — | — | 4 |
| Total | 108 |
// 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.
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
- Player plays a card and goes down to 1 card. If they have
not called
announceUnobeforehand, the engine setsvulnerableToUNO = playerId. - Any opponent can now call
challengeUno. If the target is indeed vulnerable, they draw 2 penalty cards. - If the player called
announceUnobefore playing, thenhasAnnouncedUNOis set andvulnerableToUNOstaysnull— 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:
- Non-wild playable card → automatically played (better UX, keeps the game flowing).
- Wild playable card → state returned as-is; the UI shows the colour picker so the player can choose.
- 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.
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.
16. Optimistic local updates
Without optimisation, clicking a card would follow this path:
- Compute new state locally
- Send to Supabase (network round-trip)
- Supabase broadcasts the update
- 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.
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:
| Card | Filename | Example |
|---|---|---|
| Number | {prefix}{number}.svg | Red 5 → r5.svg |
| Skip | {prefix}r.svg | Blue Skip → br.svg |
| Reverse | {prefix}x.svg | Green Reverse → gx.svg |
| Draw 2 | {prefix}p2.svg | Yellow Draw 2 → op2.svg |
| Colour Change | CC.svg | |
| Wild Draw 4 | P4.svg | |
| Card back | back.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:
- localStorage — so the input pre-fills next time you play.
- A
player_namesJSONB column on the Supabaseroomstable — 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.
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.