A real-time social deduction party game. Everyone in the room gets a secret, one player gets a different one, and the group has to work out who is lying by talking, accusing and voting from their phones.
The host opens a room, gets a four letter code, and everyone else joins by typing it. No account needed to play. Each round distributes a secret, runs through discussion and voting phases on a shared timer, and reveals who the impostor was.
The whole game is an information hiding problem. Every player is looking at the same reactive database through the same query layer, on a device they control, with devtools one keystroke away. If the round document reaches the browser with the impostor's id in it, the game is over before it starts. So the rule the codebase follows is that secrets never leave the server: each query decides what that specific player is allowed to see, and strips everything else before returning.
Word. Everyone receives the same word. The impostor receives a related hint instead, or nothing at all, and has to blend in.
Question. Everyone answers the same question. The impostor gets a
subtly different question, so their answer is plausible but slightly off.
This mode has two variants: system, where the app picks the pair, and
master, where one player writes both questions and moderates.
A isLocalMode room setting adapts either mode for a group sharing one
device rather than one phone each.
Next.js 16 client (React 19)
| useQuery / useMutation, reactive subscriptions
v
Convex backend
|
+-- rooms.ts ....... lobby, join, settings, host controls
+-- rounds.ts ...... phase machine, role distribution, result scoring
+-- votes.ts ....... vote capture and tally
+-- content.ts ..... word and question packs, custom packs
+-- schema.ts ...... 15 tables plus the auth tables, indexed by
room, session and user
There is no server of my own. Convex holds the state and pushes updates to
subscribed clients, which means the interesting work is in deciding what each
query returns rather than in moving bytes around. Auth is @convex-dev/auth,
but players are identified by a sessionId so a guest can play without
signing in and still be told apart from everyone else in the room.
Three separate layers keep round secrets on the server, in
convex/rounds.ts:
getCurrentRound is the query every player subscribes to. Before returning,
it destructures impostorId and impostorIds out of the document, so the
identity of the impostor is not merely hidden in the UI, it is absent from the
payload that reaches the browser.
getMyRole returns each player only their own secretContent, looked up by
their own sessionId. Two players calling the same query get different
answers.
getRoundResult returns identities only when round.status === "results".
Called during discussion, it returns null, so a client cannot request the
answer early by calling the reveal query directly.
The strongest version of the bluff needs the accused to be genuinely confused, which means the impostor must not be told they were picked.
During distributing and answering in master mode, getMyRole reports
role: "player" and isImpostor: false to the impostor, while still handing
them the altered question through secretContent. They answer honestly, from
a question nobody else received, and find out along with everyone else at the
reveal. The moderator is the exception: masterImpostorIds is populated only
when the caller's own role is master. In the same window getCurrentRound
also withholds questionImpostor, so the substituted question cannot be
diffed against the real one.
submitVote (convex/votes.ts) checks that the round
is in voting, that the voter belongs to this room, that spectators are
excluded, and that voter.sessionId matches the session presented. A player
cannot vote on behalf of somebody else by passing a different player id.
Voting again overwrites the existing vote rather than inserting a second one,
enforced by the by_round_voter index. Changing your mind during a discussion
is normal play, so the mutation is idempotent per voter instead of erroring.
After each vote the tally runs: once the vote count reaches the number of eligible players the round finalizes itself, with no host action needed. A tie resolves to nobody being ejected, which counts as an impostor win, matching what happens when nobody votes at all.
convex codegen writes the typed API client, and normally a build without
Convex credentials fails there. Since convex/_generated is committed,
sus/prebuild.mjs detects that it is running on Vercel
without deploy credentials, verifies the five generated files are actually
present, and skips codegen with a logged explanation. If they are missing it
exits 1 rather than letting the build proceed toward a confusing type error
later.
| Layer | Choice | Role in this project |
|---|---|---|
| Frontend | Next.js 16, React 19 | Game screens, one route per room |
| Backend | Convex | Reactive database, queries and mutations |
| Auth | @convex-dev/auth | Optional accounts, guests by session id |
| Styling | Tailwind 4, Base UI | Design system and dialogs |
| Motion | Framer Motion | Phase transitions and reveals |
| Visual | three.js, Shader Gradient | Animated background, user switchable |
| Tests | Vitest | Pure logic under src/lib |
npm test runs 18 Vitest cases across 2 files, all passing:
src/lib/__tests__/players.test.ts(15) covers player list derivation, ordering and the connected, ready and spectator states.src/lib/sound/__tests__/engine.test.ts(3) covers the sound engine.
That is honest coverage of the pure client helpers and no more. The Convex
functions, which is where the interesting authorization logic lives, have no
automated tests; that logic has been exercised by hand against the deployed
game. Adding convex-test coverage over getCurrentRound, getMyRole and
submitVote is the highest value testing work left in this repository.
There is no CI workflow. npm run lint runs ESLint 9 with
eslint-config-next.
The app lives in sus/, not at the repository root.
cd sus
npm install
npx convex dev # provisions a dev deployment, writes .env.local
npm run devnpx convex dev has to run at least once before npm run dev, since the
client needs NEXT_PUBLIC_CONVEX_URL and the generated API files. A
production build additionally needs CONVEX_DEPLOYMENT set so the schema can
be validated.
- Server side game logic is not covered by automated tests, as noted above.
- Room codes are generated with
Math.randomover a 23 letter alphabet with 5 retries on collision. Collisions are handled, but a game with secrets in it would be better served bycrypto.randomInt. - The repository also holds the prompt series in
prompts/used while building the game, and the design system notes indocs/. They are development history, not part of the running app.
Personal project. All rights reserved.
