AS.
← Back to work

07 / 11

Personal Project

CluedoCompanion

A digital detective's notebook for the board game Cluedo.

ROLE
Full-stack developer, solo project
BUILT FOR
Personal/portfolio project
STACK
Next.js 16 (App Router), React 19, TypeScript, Zustand (with persist middleware), Tailwind CSS 4, Framer Motion, lucide-react icons
PLATFORM
Web app (mobile- and desktop-responsive, client-side only)
Cluedo Companion — sign in

THE PROBLEM

Cluedo is a deduction game, but the actual deduction work happens on a flimsy paper grid that comes in the box: rows for suspects, weapons, and rooms, columns for each player, and a system of hand-drawn checks and crosses that's easy to smudge, lose, or misread by the end of the game. Figuring out whether a card has been fully eliminated means scanning a messy grid by eye every turn.

Cluedo Companion digitizes that grid. It is not a game engine — there's no board, no dice, no AI opponents — it's purely a note-taking and inference tool that sits next to the physical game and does the bookkeeping a sharp player would otherwise do manually.

THE IDEA

Model the paper sheet directly: a matrix of cards (suspects × weapons × rooms) against players, where each cell cycles through a small set of states (unknown, has-card, doesn't-have, maybe, seen-in-hand). From that matrix, derive everything else — which cards are eliminated, which categories are down to one remaining card (solved), and an overall case-completion percentage — rather than asking the user to track conclusions themselves.

The deduction is never stored, only derived — the store stays a dumb, serializable grid, and every game-logic rule lives downstream of it as a pure function.

WHAT IT DOES

01

Case setup, sharing, and the deduction board

A setup modal greets a new case asking for player count (2–6), reused later to change player count mid-game or reset the board. Multiple named cases can exist at once, each with its own markings, notes, journal, and player count. "Copy share link" serializes the active case's meaningful state into a compact base64url payload appended to the URL — no backend, no server round-trip — and opening that link on another device imports the case directly.

cluedo-companion.vercel.app
Cluedo Companion — Case setup, sharing, and the deduction board

HOW IT WORKS

Zustand store (persist → localStorage)
        │
        ├── CaseFile[]  (markings, notes, journal, playerNames, playerCount)
        │
        ▼
 derived analysis (analysis.ts, pure functions, no stored state)
        │
        ▼
   React components (Board, CasePanel, JournalPanel)

The board

The core grid: suspects, weapons, and rooms as sticky-header rows, grouped by category, against a configurable number of player columns. Tapping a cell cycles it through five states (empty → check → cross → maybe → self); a long-press opens a picker to jump straight to a specific state. Eliminated cards get a strikethrough.

Case analysis is derived, not stored

For each category, analysis.ts computes which cards are eliminated (any check or self mark anywhere in the row) versus still in play, and flags a category as solved the moment exactly one card remains — rolling up into an overall completion percentage and a fullySolved flag for the final accusation. It recomputes on every render via analyzeBoard() rather than caching conclusions in the store.

SSR-safe persistence

State lives in a single Zustand store holding an array of CaseFile objects, mirrored to localStorage via persist middleware under a versioned key. Because this is a Next.js app with SSR, the store uses skipHydration: true and rehydrates manually in a useEffect on mount, rendering a branded splash screen until hydration completes — avoiding the classic SSR/localStorage markup-mismatch bug.

Stateless sharing

share.ts strips a CaseFile down to only non-empty fields, JSON-encodes and base64url-encodes it into a URL hash, and importCase on the receiving end merges it back into a fresh case with a new id — so a shared link never collides with the recipient's existing cases.

CHALLENGES AND WHAT I LEARNED

SSR + localStorage persistence

Next.js renders on the server where localStorage doesn't exist, so naively hydrating a persisted Zustand store causes a hydration mismatch. The fix (skipHydration + manual client-side rehydrate + splash screen) is a known pattern but easy to get wrong.

Representing "maybe known" information

A simple checkbox grid can't express the nuance a real game needs (suspected vs. confirmed vs. seen-in-hand), which is why the cell model has five states instead of two, cycled by tap with a picker for direct access.

Stateless sharing without a backend

Encoding an entire case into a URL fragment keeps the app serverless, but caps how much state can be shared and required deliberately trimming the payload — only non-empty markings/notes — to keep links reasonably short.

RESULTS

Fully client-side: no backend, no database, no auth — all state in localStorage, shareable via URL only.

Supports 2–6 players with custom player names, multiple saved cases, and a free-text journal per case.

Deduction logic (elimination, per-category solve detection, completion %) is implemented as pure functions, decoupled from UI and storage.

STACK

Next.jsReact 19TypeScriptTailwind v4ZustandFramer Motion

LINKS

NEXT PROJECT

Arcade Vault

→

05 / CONTACT

Let'sbuild
somethinggreat.