Project · active

The Blind Spot

A real-time social deduction party game where one host screen drives the action while players use their phones as private controllers.

Fri, April 3, 2026
6 min read
View on GitHub
Tech Stack:
Next.js 16 TypeScript Tailwind CSS v4 Prisma ORM Neon (PostgreSQL) Pusher Framer Motion Zod
LOC 2,357
Languages TypeScript, TSX
Files 47

A real-time social deduction party game built for groups. One screen — a TV or tablet — serves as the game host, displaying room codes, live vote tallies, and dramatic reveal animations. Every player joins on their phone, receives a secret role, and participates in clue-giving, voting, and bluffing. The game handles everything from QR-code lobby entry to imposter final-guess showdowns.

Type: Web Application
Status: Active
Tech Stack: Next.js 16, TypeScript, Tailwind CSS v4, Prisma ORM, Neon (serverless PostgreSQL), Pusher (real-time WebSocket), Framer Motion, Zod
Links: GitHub


What Problem This Solves

Most social deduction games rely on physical cards or single-screen apps that force everyone to huddle around one device. That kills the tension — players see each other’s roles by accident, or the host has to manage cards manually while also playing. I wanted a clean digital replacement where the host screen is public and every player has a private, persistent view of only their own information.

The specific gap: a game where one person hosts on a shared screen (TV, projector, iPad) while 3-12 players interact privately on their phones, with real-time synchronization across all devices, automatic role assignment, and structured phases that prevent the chaos of “who goes next?”

How It Works

The system follows a multi-screen architecture with three distinct views:

Host Screen (/host/{code}) — The shared display. Shows the 4-digit room code, a QR code for instant joining, live player cards as they enter, vote progress bars during tallying, and theatrical reveal animations when an imposter is caught or a citizen is wrongfully accused. Built with Framer Motion for bouncy, neon-accented transitions.

Player Screen (/play/{code}) — The private controller. Players “hold to reveal” their role (citizen with a secret word, or imposter with a cover identity), submit one-word clues during discussion, tap to vote, and participate in the final-guess phase if caught. Uses localStorage to survive refreshes without dropping the player from the game.

Join Screen (/join) — Simple entry point. Players enter the 4-digit code or scan the QR code, pick a nickname, and land in the lobby.

Behind the scenes, Pusher Channels broadcast room state changes to every connected client. The server maintains the canonical state in Neon PostgreSQL, with Prisma handling all database operations. A transaction-safe voting resolver counts ballots, checks for unanimous completion, and advances the game phase automatically.

Why I Chose This Stack

Next.js 16 App Router over Pages Router — The App Router’s server components let me keep database queries and Pusher auth on the server without exposing credentials to the client. API routes handle room creation, voting, and game advancement cleanly. The trade-off is slightly more complexity around cookie handling in server components, but the security benefit is worth it.

Neon (serverless PostgreSQL) over SQLite or Firebase — I wanted a relational schema with foreign keys, cascading deletes, and proper transactions for vote resolution. Neon’s serverless model means zero connection management, and the free tier is generous enough for a party-game workload. SQLite would have been simpler for local dev but harder to deploy with real-time concurrent writes from multiple players.

Pusher over Socket.io or WebSocket raw — Pusher abstracts away connection management, reconnection logic, and channel presence. For a weekend project, that saved hours of infrastructure work. The trade-off is the monthly cost at scale, but for a personal project with sporadic usage, the free tier suffices.

Framer Motion over CSS transitions — The game demands theatrical UI: cards that flip, votes that tally with spring physics, and role reveals with staggered animations. Framer Motion’s declarative API made these interactions predictable and performant. CSS alone would have required exponentially more keyframe boilerplate.

What I Built

The core game engine lives in lib/game/engine.ts and handles:

  • Room code generation — 4-digit codes with collision detection and retry logic
  • Role assignment — Random imposter selection with configurable count (1-5), auto-scaled by player count
  • Word selection — 745 words across 7 categories (Food, Professions, Animals, Tech, Holidays, Movies, Brands), with a 30-day cooldown to prevent repeats
  • Vote resolution — Transaction-safe counting with tie detection, elimination logic, and automatic game outcome determination
  • Final guess phase — Caught imposters get a last chance to guess the secret word from three options

The database schema in Prisma defines Room, Player, Vote, and UsedWord models with proper relations and cascading deletes. The RoomSnapshot type in lib/game/types.ts shapes exactly what each client receives — no raw database objects leak to the frontend.

Session management uses HTTP-only cookies: a host cookie grants administrative powers (start, advance, kick, terminate), while a player cookie binds a device to a specific player identity. This prevents spoofing without requiring user accounts or passwords.

The Numbers

  • 2,357 lines of TypeScript and TSX across 47 files
  • 745 words in the dictionary, spanning 7 categories
  • 3-20 players supported per room (default cap at 12)
  • 90-second default discussion timer, configurable from 30s to 3 minutes
  • 15-second final guess window for caught imposters

What I Learned

Real-time state synchronization is harder than it looks. Pusher broadcasts are fire-and-forget. If a client misses an update (background tab, network hiccup), the UI drifts. I solved this by making every broadcast idempotent — the client fetches fresh state on reconnect and on every phase change, using Pusher events only as triggers, not as data carriers.

Cookie-based auth in server components requires careful sequencing. Next.js 16’s cookies() API is async, which means every route that checks host or player identity must await the cookie store before touching the database. I initially missed this in a few routes and got subtle race conditions where the host check failed intermittently.

Game state machines deserve explicit modeling. Early versions used boolean flags (isVoting, isRevealed) that combinatorially exploded. Switching to a single RoomStatus enum (LOBBY, PLAYING, VOTING, RESULTS, FINAL_GUESS) with a GameOutcome enum (ONGOING, CITIZENS, IMPOSTERS, TIE) eliminated an entire class of impossible-state bugs.

What Comes Next

  • Spectator mode — Let eliminated players watch without voting
  • Score persistence — Track player stats across multiple games
  • Custom word packs — Host-uploaded categories for themed nights
  • Sound design — Haptic feedback is already in place; audio cues for phase transitions would complete the theatrical experience

Built in April 2026 as a weekend exploration in real-time multiplayer architecture.