A live text-argument game. Pick a faction from an Indian show rivalry, shape your own character (2 of 5 traits + 1 of 3 relationships), talk your way past the bouncer, run an errand inside, and face the host. Your choices get scored from 0 to 100.
Four built-in rivalries:
| Show | Rivalry |
|---|---|
| Mirzapur | Kaleen Bhaiya vs Guddu Bhaiya |
| Sacred Games | Ganesh Gaitonde vs Inspector Sartaj Singh |
| The Family Man | Srikant Tiwari vs Raji |
| Heeramandi | Mallikajaan vs Fareedan |
The bouncer, the ally who vouches for you, and the host who runs the
party are all canon characters from docs/traits.json. Your character is
separate from all three — you are a player-persona aligned with the ally,
not the ally themselves.
See docs/PRD.md, docs/TechSpec.md,
and docs/PHASE.md for the spec.
backend/ Node.js + Express + ws + Mongoose
frontend/ React + Vite
docs/ PRD, TechSpec, PHASE, traits.json (canon source of truth)
docs/traits.json is the single source of truth for canon rivalries,
canon character traits, the player trait pools (5 per ally), and the
player-to-ally relationship pools (3 per ally). The server reads it
directly; the client never sees it raw.
- Node.js 18+ (tested with 24)
- npm 9+
- A MongoDB instance (optional for local dev — the backend boots and
serves
/healtheven without one, falling back to in-memory session storage) - An OpenRouter API key for live agent calls
cd backend
cp .env.example .env # fill in OPENROUTER_API_KEY
npm install
npm run dev # nodemon, watches src/, listens on :5000.env keys:
| Key | Purpose |
|---|---|
OPENROUTER_API_KEY |
Required for live Bouncer / Ally / Host / Recap calls |
OPENROUTER_MODEL |
Main model — fast, low-latency preferred |
OPENROUTER_ALLY_MODEL |
Optional faster model for the ally's at-the-door line |
OPENROUTER_ALLY_API_KEY |
Optional separate key for the ally model |
OPENROUTER_TIMEOUT_MS |
Per-agent-call ceiling. Default 45000 |
MONGODB_URI |
Mongo connection string. Empty → in-memory fallback |
PORT |
Default 5000 |
ENABLE_DEV_ROUTES |
Internal: gates the dev-only test routes |
By default the backend listens on http://localhost:5000 and exposes
/health. WebSocket upgrades are accepted on /ws/session/:id.
cd frontend
npm install
npm run dev # vite dev server on http://localhost:5173The Vite dev server proxies /api and /ws to the backend on port
5000, so the front end hits fetch('/api/...') same-origin during
development.
1. /setup
pick scenario → pick faction (a or b) → first name
→ pick 2 of 5 player-persona traits
→ pick 1 of 3 relationships to your ally
→ POST /api/session
2. /bouncer
short chat. bouncer is dry and skeptical; ally may interject.
five tension bands (calm → nearly caught).
let_in → continue. turned_away → recap + retry.
3. /inside
one of four task types is rolled:
- password_clues (guest drops hints, guess the password)
- turn_npc (NPC from the rival faction; threaten / bribe / befriend)
- mcq (3 quick reads on the host)
- trust_choice (two guests, conflicting claims)
familiarity is your standing in the room. clear the hidden threshold
to win. on win, you pick up an intel fragment — a piece of leverage
about the host that gets revealed to you for a few seconds.
4. /host-conversation
3 turns with the host. your intel fragment (if any) gives your second
answer more bite. outcome band:
respected (sum of turnScores >= +4)
awkward (sum -1..+3)
barely_scraped (sum <= -2)
5. /recap
score 0–100 + a 2–4 line screenshot card. optional photo upload +
server-rendered victory card PNG (data URL, no native deps).
All routes are JSON in / JSON out. The session id is opaque.
GET /api/scenarios — list of all rivalries
GET /api/scenarios/:scenarioId/pools?faction=a|b — trait + relationship pool for the chosen ally
POST /api/session — create: { scenarioId, faction, playerName, traits[2], relationship }
POST /api/session/:id/argue — one user line at the bouncer
GET /api/session/:id — debug view (raw suspicion / threshold)
GET /api/session/:id/task?taskType=… — roll + return the inside task (idempotent)
POST /api/session/:id/task — submit one choice; resolves against hidden config
POST /api/session/:id/host-conversation — drive one of the 3 host turns
GET /api/session/:id/recap — final score + recap card
POST /api/session/:id/victory-card — render a PNG victory card data URL
WS /ws/session/:id — live transcript stream
POST /api/session is strict. The validator enforces:
scenarioIdis a known scenariofactionis exactlyaorbplayerNameis a non-empty string after trimtraitsis exactly 2 entries, both from the chosen ally'splayerPersonaTraitspool, no duplicatesrelationshipis exactly 1 id from the chosen ally'splayerRelationshipOptionspool
Anything else → 400 { "error": "..." }.
The session doc carries two halves, separately:
{
canon: {
allyName, hostName, allyTraits, hostTraits, rivalry, show
},
playerPersona: {
name, faction, allyName, hostName, show,
playerTraits: [t1, t2],
playerRelationship: { id, label, description }
}
}The AI prompts (Bouncer, Ally, Host) receive both halves. They are instructed:
- Canon traits of the ally and the host are immutable. They never change based on player choices.
- The player is a person aligned with the ally, NOT the ally.
- The player's traits come from a 5-option pool belonging to the chosen ally. They are the player's traits, not the ally's.
- The player's relationship to the ally is one of 3 options belonging to the chosen ally. It is NOT the canon rivalry between the ally and the host.
- Add a new scenario to
docs/traits.jsonwith two characters, each having 5canonTraits, 5playerPersonaTraits, and 3playerRelationshipOptions. - Add a
SCENARIOID_TO_TRAITSIDentry inbackend/src/lib/personaConfig.jsmapping the existing scenario id to the new traits.json id. - Add the scenario id to
frontend/src/state/characterImages.jsso the Setup screen gets a portrait / show backdrop.
Nothing else changes — scenarios list, validation, prompts, and the
session doc all derive from traits.json.
cd backend
# Validates POST /api/session's 5-arg payload against every bad shape
# (1 trait, 3 traits, duplicate trait, foreign trait, foreign
# relationship, wrong-ally trait, missing firstName, etc.) and confirms
# canon + player halves persist separately on the doc.
node scripts/test-persona-validation.js
# Confirms the runner's POST shape works against POST /api/session and
# that the session has canon + player halves persisted.
node scripts/test-play-create.js
# Walks every (show × faction × taskType) = 32 combos. Uses the
# resolver tables to pick winning choices on every task, so every row is
# a guaranteed win. Logs the intel fragment that landed per combo.
node play-all-tasks.jsplay-all-tasks.js runs server-side, so it can read correctIndex /
truthIndex from the resolver and always log a win. It is the canonical
way to verify that every (show, faction) combo produces a coherent run.
Open http://localhost:5173/ and paste into the console:
// One (show, faction, taskType). Picks 2 traits + 1 relationship from
// the server's pool, creates the session, redirects to /inside.
await window.__playOneTask('mirzapur', 'a', 'password_clues');
// All 32 combos back-to-back, 10s between each, with one row per combo
// in console.table.
await window.__playAllAutomated({ delayMs: 10000 });The source for these is at frontend/public/play-one-task.js and
frontend/public/play-all-automated.js.
- Two layers of canon + player never merge. The AI prompts read both
halves separately. See
backend/src/prompts/{bouncer,ally,hostConvo}.js. - The server is the single source of truth for trait / relationship
pools. The client never sees
docs/traits.jsondirectly. - Hidden game state (bouncer threshold, insider threshold, correctIndex, truthIndex) is server-only and never sent in HTTP responses.
- Idempotent
GET /taskandPOST /host-conversation— refresh-safe. - Mongo is optional. Without a live connection the backend keeps the
session in an in-process
Mapso dev works against anyOPENROUTER_*config.