A party game about perception. One player sees a hidden point on a scale between two opposites, gives a clue, and everyone else moves a dial trying to land where they think that point is.
Each round one player is the POV Owner. They draw a card with a scale, say
Ugly on the left and Beautiful on the right, and privately see a target
somewhere along it. They give one clue. Everyone else drags a pointer to where
they think the target sits. Points are shared, not individual: the whole group
scores together, so the game is a conversation rather than a competition.
Two problems shape the code. The target is a secret that lives in the same database every player is subscribed to, so it has to be filtered per caller. And the dial has to feel like a physical instrument rather than an HTML range input, or the guessing loses all its tension.
Svelte 5 client (Vite)
| convex-svelte, reactive queries + mutations
v
Convex
|
+-- rooms.ts .... room lifecycle, getRoomView, heartbeat
+-- round.ts .... guesses, lockGuess, reveal and scoring
+-- schema.ts ... rooms (round.target), players, guesses
Room state lives in Convex. convex/schema.ts defines rooms, where the
round's secret sits in round.target, plus players and guesses, one guess
per player per round.
The target is filtered per caller, not hidden in the UI
getRoomView in convex/rooms.ts is the single query
feeding the screen. It returns the real target only when the caller is the
POV Owner, or when the round has already moved into its reveal phase.
For everyone else the field arrives as null. The secret is never sent to a
client that is not entitled to it, so no amount of inspecting the payload
gives it away early.
Waiting for the Owner to press a button after everyone has locked their guess
is dead time. lockGuess in convex/round.ts checks, after
each lock, whether every connected non-Owner has locked, and calls the reveal
itself when they have. Connectivity is decided by the heartbeat rather than by
socket state, so a player who closed their tab does not hold the round hostage.
That means reveal can be triggered from two directions at once: the automatic
path and the Owner pressing reveal manually. revealRound guards against it
with a single check, returning immediately unless the round is still in
guessing. Since scoring and the phase change happen in the same patch, the
second caller finds the phase already moved and adds nothing. The group score
cannot be incremented twice for one round.
A player who locks without having touched the dial gets a guess written at 50, the centre, rather than being skipped. The round always has exactly one guess per participant.
The pointer is not a continuous slider. src/lib/meter/geometry.ts divides the
arc into STEPS = 24 fixed positions, and snap moves any pointer position to
the nearest one. The pointer therefore always rests in the middle of a scoring
band, never on the boundary between two, which removes every argument about
which side a guess landed on.
Scoring follows from the same discretization. scoreFor compares detent
indices rather than raw values: distance 0 scores 4 points, 1 scores 3, 2
scores 2, anything further scores 0. That produces the 2-3-4-3-2 band pattern
visible on the meter face.
Every change of detent fires a click synthesized through the Web Audio API and
a vibration (src/lib/audio/clicks.ts). Combined
with the snapping, the dial behaves like a mechanical instrument with a
ratchet, which is the entire reason it reads as a game piece rather than a
form control.
While a player drags, their guess is streamed to Convex so the others can see
the pointer move. Sending on every pointer event would be hundreds of
mutations per second. src/lib/online/room.svelte.ts throttles
updateGuess to one call per 125ms, roughly eight per second, which is
smooth to watch and cheap to write. A separate heartbeat runs every 8 seconds
to keep the connected player list current.
| Layer | Choice | Role in this project |
|---|---|---|
| Frontend | Svelte 5, Vite 6 | Reactive UI on runes |
| Backend | Convex | Room state, queries, mutations |
| Binding | convex-svelte | Reactive subscriptions in components |
| Graphics | Hand written SVG | The meter, drawn from computed geometry |
| Audio | Web Audio API | Synthesized detent clicks, no audio assets |
| Tests | Vitest, svelte-check | Pure game logic and type checking |
Verified on a clean checkout:
npm run test 7 files, 29 tests, all passing
npm run check 264 files, 0 errors, 0 warnings
The suites cover the parts where a bug would be invisible rather than
obvious: geometry.test.ts for snapping and the detent scoring table,
scoring.test.ts and breakdown.test.ts for round and end of game scoring,
rules.test.ts for the badge rules, lobby.routing.test.ts for room
routing, tokens.test.ts for the design tokens, and smoke.test.ts.
The Convex functions themselves are not covered by automated tests. There is no CI workflow in this repository.
npm install
npx convex dev # provisions a deployment, writes VITE_CONVEX_URL to .env.local
npm run devnpm run dev on its own will not start the game. src/App.svelte calls
setupConvex(import.meta.env.VITE_CONVEX_URL) unconditionally at the root, and
convex-svelte throws when that URL is not a string. I confirmed the failure
mode on a clean clone with no .env.local: the page loads blank, with nothing
in the accessibility tree. Run npx convex dev first, which generates the
.env.local the client needs.
Tests and type checking need neither:
npm run test
npm run checkAGPL-3.0, see LICENSE. It keeps the source open and stops a third
party from taking the server and closing it up as their own hosted service.
