Word Traitor — Real-Time Multiplayer Social Deduction Word Game
A real-time multiplayer social deduction word game built with React, Supabase Realtime, and PostgreSQL. Players receive secret words, drop hints, debate in a live discussion phase, then vote to identify the "Traitor" — the one player holding a different but suspiciously similar word. Features Supabase WebSocket subscriptions for instant game state sync, a 9-table relational schema, procedurally generated avatars, a PWA-ready frontend, and a globally persistent music player — deployed on Netlify.
Problem Statement
Party word games like Codenames and Spyfall are beloved but require physical presence, printed cards, or expensive licensed apps. There was no lightweight, browser-first social deduction word game that a group of friends could launch instantly from a shared link — no installation, no account required to join, no paid subscription. Existing online alternatives either had terrible UX, lacked real-time sync (forcing page refreshes to see updates), or couldn't handle the core deduction mechanic: giving different but related words to one hidden traitor while keeping all civilian words identical. Building this required solving a multi-phase synchronized game loop across multiple browser tabs in real time, with a relational data model that correctly handles role assignment, secret word delivery, hint visibility, live chat, and round-by-round voting — all without a custom backend server.
Solution
Built a full-stack React SPA with Supabase as the complete backend — PostgreSQL for persistent game state, Supabase Realtime (WebSocket subscriptions over PostgreSQL LISTEN/NOTIFY) for instant cross-player sync, and Supabase Edge Functions (Deno) for server-side game logic. The frontend has no custom server — Netlify serves the static Vite build, and all game logic is driven by database state + Realtime events.
The game follows a strict 5-phase loop: Lobby → Word Reveal (Whisper) → Hint Drop → Discussion → Voting. Each phase has a dedicated route (/lobby/:roomCode, /word/:roomCode, /hint/:roomCode, /discussion/:roomCode) so every game state is a shareable URL — players can reconnect mid-game by navigating directly. The Traitor mechanic is handled by the word_pairs table: every pair has a civilian_word and a traitor_word in the same category, close enough to sound plausible but different enough to betray. The room_participants table tracks role (traitor/civilian) and is_alive per round. A global MusicContext + floating MusicPlayerWidget keeps ambient game music playing persistently across all route transitions, and driver.js powers a step-by-step onboarding tour for new players.
Code Structure
Word_Traitor_V01/
├── package-lock.json ← root lockfile
│
├── supabase/ ← Supabase backend
│ ├── README.md ← setup documentation
│ ├── todos.txt ← dev roadmap notes
│ ├── LICENSE
│ ├── functions/ ← Supabase Edge Functions (Deno/TypeScript)
│ │ └── [game-logic functions] ← role assignment, word deal, round advance
│ └── supabase/ ← Supabase CLI config + migrations
│
└── frontend/ ← React SPA
├── index.html ← 3.5KB — includes OG meta, PWA manifest link
├── vite.config.js ← Vite 7 + vite-plugin-pwa + path aliases
├── tailwind.config.js ← custom dark game theme
├── postcss.config.js
├── eslint.config.js
├── sql.txt ← Full PostgreSQL schema (9 tables, context ref)
├── package.json ← 40+ dependencies
│
├── public/ ← static assets, PWA icons, music files
│
└── src/
├── main.jsx ← React entry point
├── App.jsx ← Router + MusicProvider + HelmetProvider
├── App.css ← global styles (606B)
├── index.css ← Tailwind base + custom CSS vars (8.8KB)
├── vite-env.d.ts
│
├── pages/ ← 13 route-level pages
│ ├── Discussion.jsx ← 43KB — LARGEST: live chat + voting engine
│ ├── Lobby.jsx ← 23KB — waiting room + player list
│ ├── Index.jsx ← 22KB — landing page + room create/join
│ ├── HintDrop.jsx ← 21KB — hint submission + reveal
│ ├── Whisper.jsx ← 15.5KB — secret word reveal phase
│ ├── HowToPlay.jsx ← 6.6KB — tutorial page
│ ├── About.jsx ← 6.2KB — about page
│ ├── Terms.jsx ← 4.5KB — terms of service
│ ├── Privacy.jsx ← 4.3KB — privacy policy
│ ├── Game.jsx ← 126B — stub/redirect
│ ├── Profile.jsx ← 141B — stub
│ ├── Settings.jsx ← 146B — stub
│ └── NotFound.jsx ← 726B — 404 page
│
├── components/
│ ├── FeedbackWidget.jsx ← 5.7KB — global floating feedback form
│ ├── AvatarEditor.jsx ← 5.6KB — react-nice-avatar builder
│ ├── GlobalStats.jsx ← 3.5KB — platform-wide game stats
│ ├── MusicPlayerWidget.jsx ← 2.4KB — global floating music player
│ ├── TraitorLeftModal.jsx ← 1.5KB — disconnect/quit modal
│ ├── NavLink.jsx ← 585B — styled nav link
│ ├── game/
│ │ ├── GameScreen.jsx ← 10.3KB — core game screen layout
│ │ ├── ProfileScreen.jsx ← 5.8KB — in-game profile panel
│ │ └── SettingsScreen.jsx ← 5.3KB — in-game settings panel
│ └── ui/ ← full shadcn/ui (Radix primitives)
│
├── contexts/
│ └── MusicContext.jsx ← 3KB — global music state (play/pause/track)
│
├── hooks/
│ ├── use-mobile.jsx ← mobile viewport detection
│ └── use-toast.js ← toast notification state
│
└── lib/ ← cn() + helper utilities
Key Strategies
URL-as-game-state pattern
Each game phase has its own URL (/word/:roomCode, /hint/:roomCode, /discussion/:roomCode) — phase state lives in the URL, not in volatile React memory. Refresh, close, reconnect — you always land exactly where you left off
Supabase Realtime as the sync engine
Instead of polling Supabase every N seconds for updates, Realtime WebSocket subscriptions push database changes to every connected client instantly — live chat, hint reveals, and vote tallies update in real time across all players' browsers simultaneously
Edge Function for secret game logic
Role assignment (who is the Traitor) and word dealing (which word each player gets) run in a Supabase Edge Function — never in the client. This means no player can inspect network requests to discover their traitor status before the word reveal phase
react-nice-avatar for zero-cost identity
Procedurally generated SVG avatars (no uploads, no storage, no CDN) give every player a unique visual identity instantly — the AvatarEditor lets players customize their look before joining, increasing engagement with no infrastructure cost
Global MusicContext for atmosphere continuity
MusicPlayerWidget renders outside the route tree — music never stops during phase transitions. In a party game, losing the background music on every navigation break breaks immersion. Lifting audio state to a React Context that persists across all routes solves this permanentl
JSONB settings for zero-migration extensibility
Game room settings (hint timer, traitor count, word difficulty, adult mode, anonymous voting) are stored as a JSONB column with a sensible default. Adding a new game setting requires zero database migration — just extend the JSONB object and add a new UI control
Technical Challenges
Performance Decisions
- Supabase Realtime subscriptions replace polling entirely — zero periodic HTTP requests during active gameplay; all updates are pushed via WebSocket
- TanStack Query v5 caches all Supabase reads — player lists, word pairs, hints, and vote tallies are cached and served from memory on re-render; only invalidated on Realtime mutation events
- Vite 7 (latest major) with @vitejs/plugin-react — fastest available HMR during development, optimized chunk splitting on build
- vite-plugin-pwa generates a Service Worker that caches the app shell — second load is near-instant even on slow mobile connections; fully installable on Android/iOS
- react-nice-avatar generates SVG avatars entirely client-side — no image uploads, no CDN costs, zero latency for avatar display
- unique-names-generator runs in-browser — room codes and guest names are generated locally without a server round-trip
- Discussion.jsx (43KB, largest page) handles the heaviest real-time load — live chat + vote tallying + elimination logic all in one component; Supabase channel subscriptions are cleaned up on unmount to prevent memory leaks
- Tailwind CSS production build purges all unused classes — CSS bundle is minimal despite the large utility library
- driver.js tour runs lazily — only initializes on first visit (localStorage flag), never on repeat sessions
Trade-offs Made
Supabase free tier has connection limits and Realtime message caps; at scale (100+ concurrent games) would need a paid plan or custom WebSocket server
Phase transitions require navigation (React Router push), adding a brief page transition; state must be re-fetched from Supabase on each phase URL load
Two players could theoretically compute different tallies in a race condition if votes arrive at slightly different times; a server-side tally via Edge Function would be authoritative
No type enforcement at DB level; invalid settings values (e.g. traitors: 99) aren't caught until runtime
Limited customization of the auth UI and flow; Supabase Auth email templates are generic
Audio autoplay policies on some browsers (especially iOS Safari) block initial playback until a user gesture; requires a "tap to enable music" prompt
Service Worker caching requires explicit cache invalidation on each new deploy; stale cache bugs are possible if vite-plugin-pwa config is not set to autoUpdate mode
Lessons Learned
-
Supabase Realtime is powerful but channel management is critical. Every component that subscribes to a Realtime channel must unsubscribe on unmount — forgetting this in Discussion.jsx caused a memory leak where stale subscriptions accumulated across re-renders, triggering duplicate message events. The fix: always return a cleanup function from useEffect that calls supabase.removeChannel(channel).
-
Row-Level Security (RLS) is non-optional for secret word delivery. The round_secrets table holds each player's private word. Without RLS policies, any player could query all rows and see everyone's word — including the traitor's. Supabase RLS enforces auth.uid() = user_id at the database level, making secret delivery secure without any application-layer filtering.
-
URL-per-phase is a great pattern for multiplayer games. Having /word/:roomCode, /hint/:roomCode, /discussion/:roomCode means the game's phase state lives in the URL — not in React state that evaporates on refresh. Any player who refreshes or reconnects lands exactly where they should be. This pattern is worth using in any stateful multiplayer app.
-
JSONB settings fields are convenient but need Zod validation at the application layer. The game_rooms settings column stores { hintTime, traitors, wordLevel, adultWords, anonymousVoting } as JSONB. Because PostgreSQL doesn't enforce types on JSONB keys, any malformed settings object can silently break game logic. Parsing the settings field through a Zod schema on every read catches this before it reaches the UI.
-
unique-names-generator for room codes is a better UX than random strings. A room code like "happy-tiger-42" is far easier to share verbally ("hey, join happy-tiger-42") than "a3f9bc". For party games where players share codes out loud, human-readable identifiers are a significant UX win over UUID-based codes.
-
driver.js onboarding tours reduce first-game confusion dramatically. Without guidance, new players consistently missed the hint submit button and the vote mechanism. Adding a 4-step driver.js tour (gated by a localStorage flag so it only shows once) reduced confusion without adding a separate how-to-play interruption screen.
-
Global music context requires handling autoplay policy carefully. Browsers block audio autoplay before any user interaction. MusicContext initializes the audio element but doesn't call .play() until a click event has occurred on the page. Handling this correctly required tracking a hasInteracted state in context and deferring autoplay until the first user gesture.
Future Improvements
- Authoritative server-side vote tally via Supabase Edge Function — eliminates the race condition possibility in client-side vote counting; Edge Function receives all votes and broadcasts the authoritative result
- Voice chat integration — WebRTC peer connections (via Daily.co or Livekit) for in-browser voice during the discussion phase; transforms the game from text-chat to actual verbal deduction
- Spectator mode — room_participants role enum extended with 'spectator'; spectators see all hints and chat but cannot vote or submit hints; useful for friends who join late
- Word pack marketplace — word_pairs table extended with a pack_id column; packs (themed, adult, pop culture) toggleable per room via settings JSONB; user-submitted packs moderated via admin panel
- Replay system — every game event (hints, messages, votes, eliminations) has a created_at timestamp; a post-game replay mode lets players replay the full game timeline step by step
- Mobile-native PWA push notifications — Supabase Edge Function + Web Push API to notify players when it's their turn to vote, even if the tab is in the background
- Leaderboard and ELO rating — profiles table extended with games_played, games_won, traitor_wins; an ELO-style rating system calculated after each game result via Edge Function
- Room password protection — optional password field on game_rooms; host can create private rooms for friend groups without sharing a room code publicly
Security Considerations
- Row-Level Security (RLS) on all Supabase tables — most critical on round_secrets (auth.uid() = user_id policy) and game_votes (voter cannot vote twice — unique constraint on room_id + voter_id + round_number)
- Supabase anon key is safe to expose client-side — it provides only the permissions granted by RLS policies; the service role key is never in the frontend
- No sensitive data in client state — secret words are fetched from Supabase per-user with RLS; they're never broadcast to all room participants
- JSONB settings validated via Zod — all settings objects are schema-parsed before use, preventing injection of unexpected values like traitors: 999
- Feedback rating check constraint at DB level — rating INTEGER CHECK (rating >= 1 AND rating <= 5) enforced in PostgreSQL, not just in the form
- Chat messages have no client-side moderation — in a production release, Supabase Edge Function or a moderation API (e.g. OpenAI Moderation) should scan chat_messages before insert
- PWA Service Worker caches app shell — sensitive user data (profile, words) must never be cached by the Service Worker; only static assets and the app shell should be in the SW cache scope
- driver.js tour data stored in localStorage — only a boolean flag (tourCompleted: true); no user data or session tokens are stored in localStorage
Explore Word Traitor — Real-Time Multiplayer Social Deduction Word Game
Check out the source code or see it live.
