Skip to content

Repository files navigation

POV

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.

Play it · Case study

A POV round: the semicircular meter with its pointer, the 2-3-4-3-2 scoring bands and the Ugly/Beautiful scale card

Overview

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.

Architecture

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.

Engineering Highlights

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.

The round reveals itself, and scores exactly once

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 dial has 24 detents, and scoring is measured in detents

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.

Live dragging without flooding the backend

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.

Tech Stack

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

Testing & Reliability

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.

Running Locally

npm install
npx convex dev     # provisions a deployment, writes VITE_CONVEX_URL to .env.local
npm run dev

npm 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 check

License

AGPL-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.

About

Party game about perception: guess where a clue falls on a scale between two opposites. Svelte 5 + Convex, with a 24-detent meter drawn from computed SVG geometry and clicks synthesized in Web Audio.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages