skip to content

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:

  1. Session row per anonymous visitor (UUID + optional entered name)
  2. Per‑game tables (imposter, password, shades_signals) with host, players, optional round data
  3. 6–8 char join codes mapped uniquely to a game row
  4. Host authority: start, advance, finish
  5. Ephemeral expiration timestamps on sessions + games (cleanup job reclaims)
  6. JSONB game_data / round_data for flexible per‑game evolution
  7. Lightweight polling via route handlers (heartbeat endpoints)
  8. Player arrays stored as UUID lists for quick membership validation
  9. UI modals for session, joining, settings, about—unified design system
  10. 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 PK
entered_name varchar(128)
created_at timestamptz default now
expires_at timestamptz (sliding window)
game_data jsonb (stats, misc)

imposter / password / shades_signals

Each game table follows a similar shape.

id uuid PK
host_id uuid (session id)
player_ids uuid[] (joined players)
code varchar(8) unique
imposter_ids uuid[] (only for Imposter)
teams jsonb (Password teams)
round_data jsonb (Password perround state)
game_data jsonb (generic)
created_at, started_at, finished_at, expires_at timestamptz
category / 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:

  1. Load landing page: generate or fetch existing session cookie (maps to a session row)
  2. Player enters a display name → patch session row (entered_name), slide expires_at
  3. Player creates or joins a game using a code
  4. Server validates capacity + duplication + expiration
  5. Server appends session id into player_ids
  6. 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, no finished_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, set expires_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:

  1. Parse params (game id or code)
  2. Fetch row by id or code
  3. Validate session membership or host privileges
  4. Mutate atomic field(s)
  5. 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

  1. Arrays over relation tables – Simpler writes; no complex queries needed.
  2. Polling heartbeats – Removed websocket infra overhead.
  3. Unified JSONB blobs – Iterate game mechanics fast.
  4. Stat aggregation inline – Avoid premature analytic schema.
  5. Single cleanup cron – Handles sessions + all game types generically.

Problems Faced

ProblemSymptomFix
Orphan sessionsSessions hung around after users leftAdded expires_at + cleanup route
Divergent modal stylingInconsistent UI feelRefactored to unified design system
Cross‑game logic bloatConditional branches spreadingSegregated tables + handler folders
Player list desyncStale players in arrayHeartbeat gating + expiration pruning
Increased complexity adding a new gameFear of regressionConsistent table contract & route naming
Stats miscountsRace on quick consecutive finishesCentralized 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):

  1. Create Postgres database
  2. Configure env vars (DB URL, optional analytics)
  3. Run Drizzle migrations
  4. Deploy to Vercel
  5. (Optional) Schedule cleanup job
  6. 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