# Quiz Trivia rounds for a voice room: everyone sees the same question, answers on their own screen, and the fastest right answers score the most. The host picks a built-in question pack (English or Turkish) or pastes their own questions, and paces the game. Code: `plugins/quiz` (`@lobbyforge/quiz`; the built-in packs' questions are behind the server-only subpath `@lobbyforge/quiz/packs`). Hidden information: `packages/core/src/activity-projection.ts` (the `quiz` block). Host hydration: `apps/web/lib/prepare-plugin-action.ts` (the `quiz` branch). Tests: `plugins/quiz/src/__tests__/`, `packages/core/src/__tests__/activity-projection.quiz.test.ts`, `apps/web/e2e/activity-quiz.spec.ts`. ## How a game runs | Phase | What happens | |---|---| | `lobby` | Anyone in the channel can **join** (or stay a spectator). The host sets the quiz up: a pack or their own questions, how many questions, time per question, shuffle. **Start** needs at least one player. | | `playing` | One question is open until its **deadline**. Each player locks **one** answer. The question is revealed as soon as every player has answered, when time is up, or when the host presses **Reveal now**. | | `reveal` | The right answer, how many players picked each option (counts only — never who), the viewer's own result and the leaderboard with this round's gains. The host presses **Next question** (or **See final results** after the last one). | | `ended` | Podium (top three, ties share a place, zero scores are left off), the full ranking with each player's right answers and best streak, and the game's numbers. | The creator of the activity is the host and starts on the player list; a host who wrote the questions (and so knows the answers) should leave the list and only host — the setup screen says so. **Late joiners.** Joining mid-game is always allowed; the new player answers from the **next** question, never the one already open, and does not hold up "everyone has answered". **Spectators** see every question and reveal, and can join at any time the same way. **Leaving.** Before the start, leaving removes you. Mid-game you keep your points and stay on the leaderboard (marked "left"); an answer you locked before leaving still counts. If the last player still thinking leaves, the question is revealed. Coming back re-activates you from the next question. **Ending early.** The host can end the quiz at any time. A question that is still open is void: nobody scores it. **Play again.** The final screen gives the host **Play again** (`play-again`, the plugin's only `restartActions` entry): the quiz goes back to the lobby in the same session. Everyone still playing keeps their seat with a clean slate (score, streaks, answers), players who had left are dropped, anyone may join, and the host picks the settings and starts as the first time. Nothing of the last game reaches the new round — its deck, answers and reveal are gone. Every other action on a finished quiz is refused by the host route with 409 `session_ended`. Other players see "Waiting for the host to start another round…". **Voice.** Quiz declares `requiresVoiceRoom`: joining, answering and calling time need the activity's voice room (403 `voice_required` from outside it); `leave` works from anywhere (`allowOutsideVoice`), and watching never needs voice. **Names** come from the host, never from the client. The activity route adds everyone who acts to the session roster before the reducer runs, so on `join` the reducer stores `ctx.players.get(id).name`; the panel prefers the live name from its `players` prop, then that stored name, then "Player N". ## Timing A question has a **deadline** (server epoch ms) in the state, never "seconds left": every client counts down to the same moment. - Answers are accepted until `deadline + 1 s` (`QUIZ_ANSWER_GRACE_MS`), so an answer sent just before the deadline is not lost in transit. Answers in that second score base points only (no speed bonus). - Quiz does not use the host's timers (`timer.set` effects, docs/PLUGIN_SDK.md "Effects") yet — `ctx.timer` is an inert, deprecated stub — so "time's up" is an action — `time-up` — that any member may send and the reducer honours **only** once the server clock is past `deadline + 1 s`. Sending it early changes nothing. The host's panel sends it 1.2 s after the grace period; players' panels 3.5–5 s after it (staggered per user) in case the host is away; each retries up to four times. Spectators never send it. - Moving on from a reveal is **host-driven** (no auto-advance until Quiz moves to timer effects). ## Scoring Only **right** answers score; wrong or missing answers score 0. Points are added at the reveal — never while a question is open, so scores cannot be used to probe which option is right. ``` points = 500 base + round(500 × timeLeft / timeAllowed) speed, 0–500 + min(100 × (streak − 1), 300) streak bonus ``` `timeLeft` is measured from the moment the server received the answer to the deadline (0 inside the grace second). `streak` counts consecutive right answers including this one: the 2nd in a row adds 100, the 3rd 200, the 4th and later 300. A wrong answer or a question the player could have answered but didn't resets the streak; a question they sat out (joined later, or had left) doesn't. So a question is worth 500–1,300 points. Examples (20 s question): right after 0 s → 1,000; after 10 s → 750; at the deadline → 500; after 5 s on a 3-answer streak → 500 + 375 + 200 = 1,075. **Ranking:** by score, then right answers, then join order. Equal scores share a rank (1, 2, 2, 4) and a podium place. ## Setup | Setting | Values | Default | Notes | |---|---|---|---| | Questions | a built-in pack, or your own | the first pack in the host's language | | | Number of questions | 5, 10, 15, 20 | 10 | Capped by what the source has. | | Time per question | 10, 20, 30 s | 20 s | | | Shuffle | on / off | on | **On:** a random subset in random order, and every question's answers in random order. **Off:** the first N questions exactly as written. | Randomness is drawn by the reducer on the server (`Math.random`, injected as `env.random` so tests are deterministic); no action field can seed or steer it, and the deck never reaches a client. **How a pack game starts.** The host's panel sends only `{ type: 'start', source: 'pack', packId, language, questionCount, secondsPerQuestion, shuffle }`. On the server, the activity route's prepare step (`preparePluginAction` → `hydrateQuizPackStart` from `@lobbyforge/quiz/packs`) loads that pack's questions and puts them in the action's `questions`, replacing anything the client sent — a pack game only ever plays the pack; an unknown pack is a 404. The reducer then picks and shuffles as above. The reducer never looks a pack up itself: a pack `start` that was not hydrated plays nothing. ### Your own questions Paste them in the setup screen: ``` Which planet is closest to the Sun? *Mercury Venus Mars How many days are there in a leap year? 365 *366 367 ``` One question per block, blank lines between blocks; the first line is the question, then 2–6 answers, the right one marked with a leading `*`. "1." on questions and "A)" / "-" on answers are tolerated. The panel checks the text as you type and names the question that has a problem; the server validates again (`quizValidateAction`: non-empty text ≤ 300 characters, 2–6 different answers, one valid right answer, at most 50 questions). A JSON array in the old shape (`[{ "question", "options", "correctIndex" }]`) is accepted too, and the old `set-questions` action still works: it starts a custom quiz straight away with every question in the given order. ## Built-in packs | id | English | Türkçe | |---|---|---| | `general` | General Knowledge | Genel Kültür | | `science` | Science & Nature | Bilim ve Doğa | | `geography` | Geography | Coğrafya | Each has 24 questions with exactly four answers. The questions live in `src/packs/data/*` and are reachable only through `src/packs/server.ts` — the `@lobbyforge/quiz/packs` subpath the host imports on the server. What the panel (and the plugin's main entry) gets is the catalogue in `src/packs/catalog.ts`: id, language, title, description and question count. The Turkish packs are written for Turkish players (Turkish literature, history and places sit next to the classics), not translated from the English ones. The setup screen lists the viewer's language first, then "Other languages". Content rules the packs follow: - **Stable, uncontroversial facts only** — nothing that changes (records, rankings, populations, "the current …") and nothing disputed (for example "the longest river", where sources disagree). - The right answer's position is spread evenly over A–D (6 each), so a game with shuffle off has no pattern to learn. - Wrong answers are plausible answers of the same kind (other capitals, other planets, other authors of the period). ### Adding a pack 1. Write the questions in `plugins/quiz/src/packs/data/..ts` (copy an existing file): an array of `q(id, question, [four answers], correctIndex)`. Question ids are `--NN`. 2. Register them in `src/packs/server.ts` (the `QUESTIONS` map). 3. Add the catalogue entry — id, language, title and description in the pack's own language, question count — to `QUIZ_PACK_CATALOG` in `src/packs/catalog.ts` (and a new id to `QUIZ_PACK_ORDER`). Never import a data file from anywhere else. 4. **Every language must ship the same pack ids** — add the pack in each language, or none. 5. Check every fact against a reliable source, keep questions ≤ 120 characters and answers ≤ 40, end questions with "?". 6. `pnpm --filter @lobbyforge/quiz test` — `packs.test.ts` enforces the shape rules above (four different answers, a valid right answer, no duplicate questions or ids, the A–D spread, the same ids per language, the catalogue matching the data) and `client-bundle.test.ts` fails if a question becomes reachable from the client entries. **Adding a language** means a set of packs in it (all pack ids) plus `plugins/quiz/locales/.json` for the interface (`pnpm i18n:add` scaffolds the latter; see [TRANSLATING.md](TRANSLATING.md)). ## Fair play (hidden information) The canonical state has two secrets; everything else is public: | Field | Holds | Who sees it | |---|---|---| | `deck` | every question of the game **with** its right answer | nobody — not even the host (who may be playing a pack). Viewers get the open question as `current` (no answer) and `questionTotal`. | | `answers` | who picked what on the open question | nobody, in any phase. Each viewer gets `answeredCount` and their own `myAnswer`; the reveal publishes per-option `counts`. | The projection (`projectActivityState`, used by the REST routes and the ws-gateway alike) also strips a stray `correctIndex` from `current` and blanks `reveal` while a question is open. The leaderboard's per-round gains are public by design, so it is visible who got the last question right — but never which wrong answer anyone picked. Other guarantees: one locked answer per player per question (no retries), on the question it names (a late answer never lands on the next one); `playerId` is injected by the host from the session; answers after the deadline are refused; the score moves only at a reveal. **Pack answers never ship to browsers.** The plugin's main entry (which the host's client-safe registry imports) and the panel reach only the pack catalogue; the questions sit behind `@lobbyforge/quiz/packs`, which only the server-side prepare step imports. `client-bundle.test.ts` walks the import graph of both client entries (every `import`, `export … from` and `import()`) and fails if a data file, the server entry or any question's text is reachable. ## Actions | Action | Policy | Payload | Phase | |---|---|---|---| | `start` | host | `source: 'pack'` + `packId`, `language` (the host injects the pack's `questions` server-side) — or `source: 'custom'` + `questions`; optional `questionCount`, `secondsPerQuestion`, `shuffle` | lobby | | `set-questions` | host | `questions` (legacy: starts a custom quiz, all questions, in order) | lobby | | `join` | member, `playerId` injected | — | any but ended | | `leave` | member, `playerId` injected, `allowOutsideVoice` | — | any but ended | | `answer` | member, `playerId` injected | `index`; optional `questionIndex` | playing | | `time-up` | member | — | playing, after the deadline | | `reveal` | host | optional `questionIndex` | playing | | `next` | host | optional `questionIndex` | reveal | | `end` | host | — | playing, reveal | | `play-again` | host | — | ended (→ lobby) | Every action is checked by `quizValidateAction` before dispatch (and again by the reducer); anything invalid or out of phase leaves the state untouched. **An answer names its question.** `questionIndex` is the 0-based question the tiles were drawn for (`currentIndex` of the state the panel rendered). An answer whose `questionIndex` is not the open question is ignored — the reducer returns the same state — so an answer for question N that arrives late or is retried after the host pressed **Next question** is never recorded against N+1 (or rejected there as a second answer). An answer without a `questionIndex` (a panel loaded before it, still open after a deploy) answers the open question, as before; `null` counts as absent, and anything else that is not a non-negative integer is a 400. The question number is public (`currentIndex`, `current.index`), so sending it reveals nothing. An answer for question N that arrives during N's reveal was already refused (409 `wrong_phase` from the host, and the reducer only takes answers while a question is open). The host's **Reveal now** and **Next question** name their question the same way (`reveal` and `next` with `questionIndex`, the question on screen when the button was drawn). A Reveal now that lands after the host moved on does not reveal the next question early, and a second Next — a double tap, or the host and a moderator together — does not skip a question that was answered and revealed in the meantime. Without a `questionIndex` they act on the current question, as before. `questionIndex` restarts at 0 with every game (`play-again`), so it does not guard against a press that arrives a whole game late. ## State `QuizState` v2 (`plugins/quiz/src/state.ts`): `phase`, `settings`, `players` (join order: score, right answers, answered, streak, best streak, last gain and result, `eligibleFrom`, `active`), the secret `deck` and `answers`, `questionTotal`, `questionsRevealed`, `currentIndex`, `current`, `questionStartedAt`, `deadline`, `reveal`, `startedAt`, `endedAt`, `endReason`. `migrateQuizState` upgrades sessions written by older builds (no `version`: a pasted `questions` list, `currentAnswers`, `playerScores`): the questions become the deck, players keep their correct answers (500 points each), a game in progress continues without a timer (the host reveals), and a revealed question keeps its counts. It is idempotent and returns a current state as the same object. ## Accessibility - Answer tiles are real buttons: Tab reaches them, Enter or Space answers. Each has a letter **and** a distinct colour (the colour is never the only cue); the accessible name is "B: Jupiter". - While the question has focus, `1`–`4` or `A`–`D` answer (up to 6 for custom questions). The handler sits on the question region, never on the window, and ignores keys typed in inputs and chords with Ctrl/Alt/Meta. - After answering, the tiles stay focusable (`aria-disabled`), so focus is not lost. A new question and a reveal move focus to the question / the answer heading when focus was in the panel. Status lines ("Answer B is locked in…", "Time's up!", your result) are live regions. - Everything is built from the activity UI kit and follows the dark, dim and light themes; answer swatches carry dark text at 7:1 or better. ## Files ``` plugins/quiz/ locales/en.json, tr.json interface strings (+ catalog.summary) src/index.ts the GamePlugin: manifest, policies, glue src/state.ts types, constants, initial state, migration src/actions.ts validation, reducer, scoring src/deck.ts building a game's deck (shuffles) src/roster.ts eligibility, ranking, podium src/custom.ts the paste format src/packs/catalog.ts pack catalogue (client-safe: titles, counts) src/packs/server.ts SERVER ONLY: packs with questions, hydration (published as @lobbyforge/quiz/packs) src/packs/data/ the questions (server only) src/view.ts the panel's defensive read of the state src/renderClient.tsx the panel src/ui/ lobby/setup, question, reveal, final views ```