WorkCase study

Lexicon Bluff

A real-time party game for phones: one player holds a rare word's real definition, everyone else writes a fake.

Role
Solo · game design, server, phone UI, deployment
When
2026
Stack
TypeScript, React 19, Vite, Fastify, Socket.IO, Zod, Tailwind CSS, Docker
  • 52,218obscure words in the deck
  • 3–12players per room
  • 45 sto reconnect without losing your seat
  • 34automated tests

The problem

Fictionary (sold as Balderdash) is simple at a table. Someone reads out an obscure word, everyone writes a fake definition, the real one goes into the pile, and people vote for the one they believe. On phones, the hard part is secrecy. If the browser holds the whole game state, anyone with developer tools can read the answer. And phones drop their connection all the time: a locked screen or a lift ride shouldn’t cost someone their seat or their score.

I wanted a version that needs nothing but a link. No accounts, no app, no third-party realtime service.

What I built

Lexicon Bluff is live at lexicon.pergata.com. One player creates a room and shares a four-letter code, a link or a QR code. Each round, the reader is offered three rare words, can swap all three for fresh ones, and picks one. Everyone else writes a convincing fake. The real definition is shuffled into the ballot and the room votes. A correct guess earns a point; every vote a bluff attracts earns its author a point.

There are two voting modes. On-screen mode shows the ballot on every phone. Read-aloud mode, the default, sends the texts only to the reader, who reads them out, while everyone else gets large letter buttons. The game is better when people look at each other instead of their screens.

The frontend is React 19 with Vite, Tailwind and Motion, designed for phones first. The backend is Fastify and Socket.IO in one Node process that also serves the built app, shipped as a single Docker image with a health check. The word list is generated: a script takes the Project Gutenberg text of Webster’s Unabridged Dictionary, drops the 10,000 most common English words, keeps definitions of 5 to 25 words and normalises them.

The hard parts

Keeping secrets on the server. The server owns every transition: lobby, drafting, writing, voting, reveal, scores. Clients never receive the canonical state. Before each broadcast, the server builds a separate view for every player. Only the reader sees the word options and the real definition, submissions stay hidden until voting, and authorship and votes appear only at the reveal. This is the ballot part of server/game/project-state.ts:

let ballot: BallotOption[] = [];
if (state.status === "VOTING") {
  const showText = state.settings.votingMode === "SCREEN" || isReader;
  ballot = room.currentBallot.map((entry) => ({
    id: entry.id,
    label: entry.label,
    ...(showText ? { text: entry.text } : {}),
    ...(entry.authorId === playerId ? { authorId: playerId } : {}),
  }));
} else if (isRevealed && room.currentResult) {
  ballot = room.currentResult.ballot.map((entry) => ({ ...entry }));
}

Each player learns which entry is their own and nothing else. In read-aloud mode only the reader sees the texts, so without that hint you couldn’t tell which letter is your bluff. The phone disables it, and the engine rejects self-votes anyway.

Reconnects that don’t wreck the round. Each browser keeps a stable player ID and its current room in local storage and rejoins as soon as the socket reconnects. The server tracks sockets per player, so two tabs count as one seat. When a player’s last socket drops, a 45-second timer starts, and coming back cancels it. If it runs out, the outcome depends on the phase. In the lobby the player is removed. A missing reader triggers a fresh round with a new reader. Anyone else becomes an observer, and the engine re-checks whether all active players have written or voted, so the round finishes without waiting for a phone in someone’s pocket.

Surviving a restart. Every broadcast also snapshots all rooms to a JSON file, written to a temporary file and renamed, so a crash can’t leave half a file behind. On boot, the server restores the rooms, marks everyone as disconnected and starts their grace timers. A deploy in the middle of a game costs players a reconnect, not their scores.

The tests drive the engine directly, and also play full rounds in both voting modes against a real server with real Socket.IO clients.

Status

Live and playable. Room state lives in one process and one file, so it runs as a single replica; scaling out would need shared storage and a Socket.IO adapter. The game is English only. The repository starts with a sketch from January 2024 that already planned German, but that needs a German word list with a clean licence. The common-word list used for filtering is only cleared for personal and research use, so it would need replacing before any commercial use.