Overview
I wanted fast, zero account party games you can start on a call. No signup friction. No lobby spam. Just: enter a name, pick a game, give friends a code. Off the shelf options were heavy or locked behind monetization. So I built my own controlled environment with short lived sessions and explicit game lifecycles.
You’ll see the moving parts: Sessions. Join codes. Game row lifecycle. Expiration policy. Host authority. Player state. Minimal polling. Failure recovery. Cleanup. Design system convergence. All intentionally small.
Outcome: a platform where each game type (Imposter / Password / Shades Signals) shares core primitives but stays isolated. You can extend with a new game by adding a table + routes + UI surface—no rewrite.
Why I Built It
Initial goal: “Let friends play social deduction + clue games instantly.” Early prototype: a single Imposter table and ad‑hoc arrays. Problems appeared fast:
- Stale players that closed tabs stayed in arrays
- Rounds leaked data between games
- No expiration = database creep
- Hard to add a second game without branching logic hell
- UI modals diverged visually as I iterated
Pain forced structure: explicit session model, per‑game tables, consistent join codes, cleanup cron, composable modal design.
Scope only expanded when friction showed up in testing—not feature chasing.
System Overview
Core building blocks:
- Session row per anonymous visitor (UUID + optional entered name)
- Per‑game tables (
imposter
,password
,shades_signals
) with host, players, optional round data - 6–8 char join codes mapped uniquely to a game row
- Host authority: start, advance, finish
- Ephemeral expiration timestamps on sessions + games (cleanup job reclaims)
- JSONB
game_data
/round_data
for flexible per‑game evolution - Lightweight polling via route handlers (heartbeat endpoints)
- Player arrays stored as UUID lists for quick membership validation
- UI modals for session, joining, settings, about—unified design system
- Simple stat tracking appended to session
game_data
Nothing “real‑time websocket” yet; polling cadence + fast responses are enough at current scale.
Data Model
Drizzle + Postgres with table name prefixing (games_
) to scope logically.
sessions
Stores ephemeral identity + optional aggregate stats.
id uuid PKentered_name varchar(128)created_at timestamptz default nowexpires_at timestamptz (sliding window)game_data jsonb (stats, misc)
imposter / password / shades_signals
Each game table follows a similar shape.
id uuid PKhost_id uuid (session id)player_ids uuid[] (joined players)code varchar(8) uniqueimposter_ids uuid[] (only for Imposter)teams jsonb (Password teams)round_data jsonb (Password per‑round state)game_data jsonb (generic)created_at, started_at, finished_at, expires_at timestamptzcategory / chosen_word (game specific)max_players / num_imposters (Imposter specific)
Consistency decisions:
expires_at
always present once game starts or after idle- Arrays instead of join table for simplicity (volume low, operations atomic)
- JSONB for evolving shape without migrations per micro change
Session & Join Flow
Flow when a new player arrives:
- Load landing page: generate or fetch existing session cookie (maps to a session row)
- Player enters a display name → patch session row (
entered_name
), slideexpires_at
- Player creates or joins a game using a code
- Server validates capacity + duplication + expiration
- Server appends session id into
player_ids
- Heartbeat calls (or any action) extend
expires_at
for both session + active game
If a tab disappears and no heartbeat occurs, cleanup jobs later prune.
Heartbeat & Expiration Strategy
Rather than websockets, periodic fetches to /heartbeat
for each active game update:
expires_at = now + X minutes
- Skip if game already
finished_at
Missed heartbeats → game eligible for cleanup. Low cost. No background worker complexity beyond a cron hitting API cleanup route.
Game Lifecycle
States (implicit via timestamps):
- Lobby (no
started_at
) - Active (
started_at
set, nofinished_at
) - Finished (
finished_at
set, maybe scoreboard or reveal phase) - Expired (current time >
expires_at
, candidate for deletion/prune)
Host endpoints:
- Start game (
started_at = now
) - End round / next round (mutate
round_data
/game_data
) - Finish (
finished_at = now
, setexpires_at
+1h for post‑game viewing)
Statistics Tracking
A light per‑session stats object aggregated in game_data.stats
:
{ imposter: { played, won, winRate }, password: { played, won, winRate }, lastPlayed, totalGames, totalWins, overallWinRate}
Update triggers when a game finishes and calls updatePlayerGameStats(sessionId, gameType, won)
.
No separate stats table yet—keep it inline until scale or query patterns justify extraction.
API Layer Patterns
Pattern per route handler:
- Parse params (game id or code)
- Fetch row by id or code
- Validate session membership or host privileges
- Mutate atomic field(s)
- Return JSON
{ ok: true, ...data }
or error with status
Endpoints exist for:
- Create game (allocates code)
- Join by code
- Heartbeat
- Start / next round / finish
- Vote flows (Imposter) / select word / submit clues (Password)
Design goal: each handler is single responsibility; no giant orchestration function.
Failure Handling & Constraints
Edge cases considered:
- Double join: check membership before pushing id
- Game full: reject early with 409
- Expired game: 404 to clients → UI prompts new lobby creation
- Late heartbeat after finish: allowed, extends post‑game view period
- Host disconnect: other players keep lobby; no auto reassignment yet
UI / Modal System
Early modals diverged (different borders, spacing). Refactored to a shared design language:
- Container:
bg-card border border-border shadow-2xl rounded
max-w-lg - Icon circle with subtle gradient for recognizability
- Hierarchy: large gradient title → descriptive muted paragraph → actionable buttons
- Feature grid for quick comprehension on join + about
Improved join flow with a small multi‑step state machine: welcome → details → connecting
to reduce overwhelm for first‑time players while letting returning players skip.
Interesting Simplifications
- Arrays over relation tables – Simpler writes; no complex queries needed.
- Polling heartbeats – Removed websocket infra overhead.
- Unified JSONB blobs – Iterate game mechanics fast.
- Stat aggregation inline – Avoid premature analytic schema.
- Single cleanup cron – Handles sessions + all game types generically.
Problems Faced
Problem | Symptom | Fix |
---|---|---|
Orphan sessions | Sessions hung around after users left | Added expires_at + cleanup route |
Divergent modal styling | Inconsistent UI feel | Refactored to unified design system |
Cross‑game logic bloat | Conditional branches spreading | Segregated tables + handler folders |
Player list desync | Stale players in array | Heartbeat gating + expiration pruning |
Increased complexity adding a new game | Fear of regression | Consistent table contract & route naming |
Stats miscounts | Race on quick consecutive finishes | Centralized updatePlayerGameStats helper |
Security & Abuse Surface
Current protections minimal but deliberate:
- Name length limits (DB varchar constraint)
- Join code uniqueness + upper length bound
- No trust of client membership claims (always validate server‑side)
- Expiration pruning = reduces stale enumeration surface
Future hardening (see below) will address spam and rate abuse.
What I’d Add Next
- Lightweight rate limiter (IP + session) on join / create
- Duplicate name soft disambiguation (append emoji or digit)
- Websocket layer (or Server Sent Events) for instant state instead of poll
- Replay / summary view for finished games
- Admin dashboard: live games, force expire, metrics
- Game plugin interface: register rules + UI mount points
- Redis or in‑memory cache for hot game lookups
- Optimistic UI transitions with reconciliation
Deployment & Ops
Current stack:
- Next.js (App Router) for UI + API route handlers
- Drizzle ORM + Postgres (Neon / Supabase / RDS compatible)
- Vercel deploy (edge not required yet; Node runtime fine)
- Cron (Vercel scheduled function) hits cleanup endpoint
- Tailwind + component primitives for rapid UI iteration
Provision steps (generic):
- Create Postgres database
- Configure env vars (DB URL, optional analytics)
- Run Drizzle migrations
- Deploy to Vercel
- (Optional) Schedule cleanup job
- Share a code—play
Closing
Small primitives compose into multiple game types without over‑engineering. Each constraint (expiration, code uniqueness, isolated tables) keeps entropy controlled while leaving space to ship new mechanics quickly.
If you want to build something similar: start with sessions + one game table. Add structure only when it hurts. Keep networking boring until latency demands more. Align UI components early.
Ship the first version. Watch people break it. Evolve.
Thanks for reading.
Source: https://github.com/oyuh/games