A real-time multiplayer murder mystery party game built with Next.js, Supabase, and TypeScript.
- Real-time Multiplayer: Players join game rooms and interact in real-time
- Role-Based Gameplay: Investigator and suspects, with the culprit chosen per round
- Secret Character Sheets: Each player receives unique markdown-formatted character info
- Consensus System: Players must all be ready to proceed
- Dramatic Accusation: Investigator makes accusations with animated reveals
- Heartbeat System: Automatic detection of disconnected players
- Mystery Management: Create, edit, delete, and upload mysteries via admin console
- JSON Schema Validation: Mysteries validated against strict schema
- Base64 Support: Upload base64-encoded mystery data
- Responsive UI: Built with Material-UI and Framer Motion animations
-
Install dependencies
pnpm install
-
Start Supabase locally (requires Docker)
npx supabase start
-
Set up environment
cp .env.local.example .env.local # The example file has local development defaults that work out of the box -
Seed sample mysteries
pnpm seed:mysteries
-
Start development server
pnpm dev
-
Manage mysteries
- Browse mysteries at
/admin/mysteries - Create new mystery at
/admin/mysteries/new/edit - Upload JSON mysteries at
/admin/mysteries/upload
- Browse mysteries at
-
Create a game by clicking "New Game" on the home page
See SETUP.md for detailed setup instructions.
- Frontend: Next.js 16 with React 19, TypeScript
- Backend: Supabase (PostgreSQL, Realtime, Auth)
- Styling: MUI 7 with Emotion
- Animations: Framer Motion
- Markdown: react-markdown with remark-gfm
- Auth: Custom JWT sessions with jose
- Admin creates game session → receives join code
- Players join via join code → enter lobby
- Players mark themselves ready
- Admin selects mystery and starts game
- Character sheets are randomly distributed (one investigator + suspects)
- Players receive secret character sheets
- Investigation phase (free-form discussion)
- Investigator makes accusation
- Results revealed with animations
- Option to continue with next mystery
whodunit-party/
├── src/
│ ├── app/ # Next.js app router
│ │ ├── admin/ # Admin dashboard and mystery management
│ │ ├── api/ # API routes (sessions, join, accusations, mysteries)
│ │ ├── join/ # Player join flow
│ │ ├── lobby/ # Game lobby with ready system
│ │ └── play/ # Character sheets and gameplay
│ ├── hooks/ # Custom hooks (heartbeat)
│ ├── lib/ # Utilities (auth, logging, supabase)
│ └── types/ # TypeScript definitions
├── schemas/
│ └── mystery.schema.json # JSON schema for mystery validation
├── supabase/
│ └── migrations/ # Database schema
└── public/
└── characters/ # Role placeholder images
Access the mysteries admin console at /admin/mysteries to:
- View all mysteries in a table
- Create new mysteries with the form editor
- Edit existing mysteries
- Delete mysteries
- Upload mysteries via JSON
Mysteries must conform to the schema defined in schemas/mystery.schema.json.
Required fields:
title(string, 1-200 chars)description(string, min 10 chars, supports markdown)innocent_words(array of exactly 3 strings)guilty_words(array of exactly 3 strings)character_sheets(array, min 2 items, must include exactly one investigator and at least one suspect)
Character sheet fields:
role: "investigator" | "suspect"dark_secret(string, min 10 chars)alibi(string, min 10 chars)image_path(optional string)
New packs never define the culprit. Every suspect sheet must contain a
dark_secret that can work as a confession if that suspect is chosen at runtime.
Legacy uploads using guilty or innocent are accepted as import aliases and
normalized to suspect.
Via Form Editor: /admin/mysteries/new/edit
- Fill out the form with mystery details
- Add character sheets dynamically
- Validate on save
Via JSON Upload: /admin/mysteries/upload
- Paste JSON array of mysteries
- Optional: Check "Base64 encoded" box for base64 input
- Schema validation runs automatically
- Detailed error messages for validation failures
Example Mystery JSON:
[
{
"title": "Murder at the Manor",
"description": "## The Crime\\n\\nLord Blackwood was found dead...",
"language": "en",
"author": "Example",
"theme": "SERIOUS_MURDER",
"innocent_words": ["manuscript", "inheritance", "betrayal"],
"guilty_words": ["ledger", "poison", "desperate"],
"character_sheets": [
{
"role": "investigator",
"dark_secret": "You secretly gambled away your family fortune.",
"alibi": "I was in the conservatory reading all evening."
},
{
"role": "suspect",
"dark_secret": "I confess everything: I poisoned the victim to prevent exposure.",
"alibi": "I was in my room writing letters."
},
{
"role": "suspect",
"dark_secret": "I confess everything: my affair with the victim's spouse gave me a motive, and I used the confusion to act.",
"alibi": "I was walking in the garden."
}
]
}
]- Supabase Realtime channels for live updates
- Player status synchronization
- Automatic disconnect detection via heartbeat
- Minimum 2 players required
- One investigator sheet is assigned each round
- Suspect sheets are distributed to remaining players
- The guilty player is chosen deterministically by the server for that round
- Markdown-formatted secrets and alibis
- Three words to place in conversation
- Optional inspiration text for alibis
- Role-specific placeholder images
- Investigator-only accusation privilege
- Player selection modal
- Blood drip animation for accused
- Immediate result reveal
# Development server
npm run dev
# Type checking
npm run typecheck
# Linting
npm run lint
# Production build
npm run build
npm startDeploy to Vercel:
- Push code to GitHub
- Import project in Vercel
- Set root directory to
whodunit-party - Add environment variables (see below)
- Deploy
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL |
✅ | Supabase Project URL (from Dashboard → Settings → API) |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
✅ | Supabase anon public key |
SUPABASE_SERVICE_ROLE_KEY |
✅ | Supabase service_role secret key (server-side only) |
JWT_SECRET |
✅ | Secret key for signing player session JWTs |
Used to sign and verify player session tokens. Without this, players cannot join games.
Generate a secure secret:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"Example output: pFcxARPGuJXn9kZhwtr+qEw714KGF5yS/QPd9odbBDI=
Add to:
- Local development:
.env.localfile - Vercel: Project Settings → Environment Variables (all environments)
.env.local file is gitignored.
This is a personal project, but feel free to fork and customize for your own use!
MIT