# Hushle — Taboo-style voice game Hushle is a Taboo/Tabu-style word game played in a live voice room. Each turn one player explains the word on a card without saying any of the forbidden words listed under it; their teammates guess out loud. The other team sees the card too and presses **BUST** when a forbidden word slips out. The host scores every card and moves the game from turn to turn; the team with the highest score when the game ends wins. This page covers the rules as the reducer implements them, who sees what, the panel, and how the host app runs it. The SDK contract (manifest, action policies, test harness, the UI kit) is in [`docs/PLUGIN_SDK.md`](./PLUGIN_SDK.md); the HTTP routes are in [`docs/ACTIVITIES.md`](./ACTIVITIES.md). ## Quick start 1. In the lobby, join a voice channel and open **Play together** (the Activities hub in the centre column). Click **Hushle** — you are the host. 2. **Lobby.** Pick a word pack (or, when the server lists no packs, the card language), the turn timer, cards per turn, players per team and the card difficulty mix. Click **Start Hushle**. 3. **Team setup.** Everyone in the voice room is listed by name. **Split into two teams** seats them at random (an odd one out becomes the floater), or the host adds teams and seats people one by one. Click **Start first turn** — the first team's first player explains. 4. **A turn.** One clock for the whole turn: the explaining team gets as many cards as they can before it runs out. The explainer and the other team see the card; the explainer's teammates do not. The host presses **Got it**, **Skip** or **Penalty** under the card; anyone on the other team presses **BUST**. The host's tools also offer **Next card** (swap without scoring), a different **Explainer**, **End turn** and **End game**. 5. The turn is over when its time is up (no more cards are scored) or its cards are used up. The host clicks **Start next turn**: the other team plays, and the next player in that team's rotation explains. 6. **End game** shows the winner, the final scores and a recap. The host can **Start new game** with the same pack and settings. ## State machine ```ts type HushlePhase = 'lobby' | 'team_setup' | 'playing' | 'ended'; ``` ``` start-game start-turn lobby ─────────────► team_setup ────────► playing ◄──► between turns │ │ └──── end-game ────┴─────► ended ── start-game ──► team_setup ``` - **lobby** — `createHushleInitialState()`. Only `start-game` means anything here. - **team_setup** — `set-teams` replaces the whole roster (every edit sends every team); `start-turn` begins play. - **playing** — a turn runs while `timer.startedAt` is set, until its deadline `timer.endsAt`; after the deadline its scoring is over. When the turn's `cardsPerTurn` are used up (or the deck runs out), the reducer clears the card and the timer but stays in `playing`: that is the **between turns** state. Either way, `end-turn` starts the next team's turn. - **ended** — from any phase via `end-game`. Scores stay; `start-game` goes back to team setup with a fresh deck and no teams. ## State shape (version 4) ```ts type HushleState = { version: 4; // HUSHLE_STATE_VERSION phase: HushlePhase; teams: Array<{ id; name; playerIds: string[]; score; correctCount; passCount; penaltyCount; nextExplainerSlot: number; // where this team's own explainer rotation stands }>; floaterPlayerId: string | null; // one extra player for odd counts; in every team's rotation turnNumber: number; // the current turn, 1 for the first; 0 before play currentTeamId: string | null; currentExplainerId: string | null; currentCard: HushleCard | null; // { id, language, word, forbiddenWords, difficulty, category? } cardSeq: number; // which deal is in play: +1 at every draw, never reset; public deck: HushleCard[]; // SERVER ONLY — never sent to a client deckIndex: number; // legacy, unused by the weighted draw usedCardIds: string[]; // SERVER ONLY — the last one is the current card settings: { turnDurationSeconds: number; // ≤ 300, default 60 cardsPerTurn: number; // ≤ 100, default 15 language: string; // any language tag packId: string | null; // pack slug or UUID teamSize: number; // ≤ 16, default 2 difficultyDistribution: { easy; medium; hard }; // weights, default 0.6 / 0.3 / 0.1 }; timer: { startedAt: string | null; // when the running turn started; null between turns durationSeconds: number; paused: boolean; endsAt: string | null; // the turn's deadline — every client counts down to it }; cardsPlayedThisTurn: number; totalCardsPlayed: number; createdBy: string | null; createdAt: string | null; }; ``` `migrateHushleState` upgrades older rows on every read (see [State versioning](#state-versioning)). ## Reducer `plugins/hushle/src/actions.ts` — `hushleReducer(state, action)`, pure and defensive: an action that does not fit the phase returns the state unchanged. | Action | Effect | |---|---| | `start-game` | → `team_setup`. Deck: the pack's cards, injected by the host from `card_packs` (`apps/web/lib/prepare-plugin-action.ts`; client input is overwritten), else the bundled deck for the language. Settings are clamped; the difficulty weights are renormalised. | | `set-teams` | Replaces the roster. Empty names are dropped, names cut to 40 characters, each team trimmed to `teamSize`. The floater is kept only if they are on no team. Every team's rotation starts at its first player. | | `start-turn` `{ turnNumber? }` | → `playing`: the given team, the given explainer (or the next in the team's rotation), a card drawn by difficulty weight, the turn's clock started. Ignored when `turnNumber` names another turn. | | `set-explainer` | Hands the turn to someone else; the team's rotation then continues after them. Carries `turnNumber` like the other turn moves: a late pick for a turn that is over changes nothing. | | `next-card` `{ cardSeq? }` | A new card without scoring; counts toward the turn's cards. Refused once the time is up or between turns, and ignored when `cardSeq` names an earlier deal (see [Stale presses](#stale-presses)). | | `correct-guess` / `pass` / `penalty` `{ cardSeq? }` | +1 / 0 / −1 for the explaining team, then the next card on the same clock — or, at the turn's card budget or an empty deck, the end of the turn. Refused once the time is up, between turns, and when no card is in play (a turn that started on an empty deck); ignored when `cardSeq` names an earlier deal. | | `bust-forbidden` | The other team's buzzer: the same as `penalty`, accepted only when `bustedBy` (injected by the server from the session) sits on a team other than the explaining one, AND `cardId` names the card on screen. Two opponents pressing at once (or a double tap) send the same `cardId`: the first costs the penalty and draws the next card, the second names a card that is gone and is ignored — one penalty, no burnt card. `validateAction` answers 400 for a BUST without a `cardId`. | | `end-turn` `{ turnNumber? }` | The next team in order; the next player in its own rotation explains, and its turn starts at once on a fresh clock. Ignored when `turnNumber` names another turn, so two presses end one turn. | | `end-game` | → `ended`: the card and the timer are cleared, scores kept. | Card draw: the reducer picks a difficulty tier by the weights, then an unused card of that tier, falling back to any unused card. A card is never drawn twice in a game. ### Stale presses A press can reach the server after the card it was meant for is gone. An opponent presses BUST on card A while the host presses Got it: the BUST lands first (A costs a point, card B is dealt), and Got it would then score B, which nobody explained. Two moderators pressing at once do the same. So every draw bumps `cardSeq` (the start of a turn, a card scored, skipped or busted, `next-card`), and the host's card moves carry the `cardSeq` of the state their button was drawn from. One that names an earlier deal — or a later one — is ignored: the reducer returns the same state object, so the press changes nothing (no score, no burnt card, no audit row), exactly like a BUST for a card that is gone. The actions route answers a plain 200 with the current state, which shows the presser the new card. `cardSeq` is not reset by `start-game`, so a late press from the last game cannot match a deal of the new one. - **Why a counter and not the card id:** the host who does not play and the explaining team never receive `currentCard`, so they cannot name it. `cardSeq` is public and says only how many cards were dealt (the same as `usedCardCount`), never which. BUST keeps its `cardId`: only opponents press it, and they see the card. - **Without a `cardSeq`** (a panel loaded before v4 and still open after a deploy, or a session read raw from the realtime gateway before its first v4 write) the move applies to the card in play, as before. A present `cardSeq` that is not a non-negative integer is a 400 from `validateAction`; `null` counts as absent. - **No card, nothing to score.** When the host ends a turn on the last card, the next turn starts on an empty deck: the clock runs but `currentCard` is null. Got it, Skip and Penalty are refused then (the same object comes back), and Next card and BUST have nothing to act on either. End turn and End game still work. The turn moves follow the same rule with the public `turnNumber`. Start first turn, End turn and Start next turn send the `turnNumber` of the state their button was drawn from; `start-turn` and `end-turn` ignore one that names another turn. So a double tap, or the host and a moderator pressing together, ends one turn instead of cutting the next team's turn short the moment it begins, and a second press of Start first turn does not restart the turn with a new card and clock. Absent or `null`, the move acts on the turn as it stands; any other non-integer is a 400. `turnNumber` restarts at 0 with every game (it is the "Turn 3" the panel shows), so unlike `cardSeq` it does not guard against a press that arrives a whole game late. ### Turns and the explainer rotation Teams play in seat order. Each team has its **own** rotation — its players in seat order — and each time the team plays, the next player in it explains. With two teams of two, four turns give all four players one turn each: A1, B1, A2, B2, A1, … An odd player count leaves one player as the **floater**. The floater sits on no team; instead they have a slot in **every** team's rotation, so in a full round they explain once for each team while everyone else explains once. Their slot is staggered from team to team — after the first player in the first team's rotation, half a round later in the second's — so their two turns fall apart, not back to back. With A = (a1, a2), B = (b1, b2) and a floater f, the round is a1, b1, f, b2, a2, f. A team with no players of its own is explained for by the floater every time. When the host names the explainer (`start-turn` with an `explainerId`, or `set-explainer` mid-turn), that team's rotation continues after whoever actually explained, so nobody is skipped. `src/rotation.ts` holds the rotation; the reducer, the state migration and the panel's "Next up" all read it. ### The turn timer A turn has **one** clock. It starts with the turn — `timer.startedAt`, and the deadline `timer.endsAt` — and scoring a card does not restart it: the explaining team has the whole duration for as many cards as they manage. Every client counts down to the same `endsAt`. Once the deadline has passed (plus a 2 s grace, `HUSHLE_TIME_UP_GRACE_MS`, for a last-second tap already on its way), the turn's scoring is over: the reducer refuses Got it, Skip, Penalty, BUST and Next card until the host starts the next turn. ## Action policies ```ts actionPolicies: { 'start-game': { role: 'host' }, 'set-teams': { role: 'host' }, 'start-turn': { role: 'host' }, 'set-explainer': { role: 'host' }, 'next-card': { role: 'host' }, 'correct-guess': { role: 'host' }, pass: { role: 'host' }, penalty: { role: 'host' }, 'bust-forbidden': { role: 'member', actorFields: ['bustedBy'] }, 'end-turn': { role: 'host' }, 'end-game': { role: 'host' }, }, ``` The host moderates: only they can hear whether a guess was right. BUST is the one player action — any server member in the voice room may send it (Hushle declares `requiresVoiceRoom`, so the host refuses it from outside the room with `voice_required`), the server fills `bustedBy` with the caller, and the reducer checks the team and the card. `restartActions: ['start-game']`: on the final screen the host's **Start new game** sends `start-game` with the last game's pack and settings, which the host route accepts from the `ended` phase (back to team setup, the pack's deck injected again); every other action on a finished game is refused with 409 `session_ended`. If the host leaves the voice room, hosting moves after 60 s to the longest-present participant there ([PLUGIN_SDK.md → Voice, hosting and play again](PLUGIN_SDK.md#voice-hosting-and-play-again)). ## Who sees what The server projects the state for each viewer before it leaves (`packages/core/src/activity-projection.ts`, used by the web routes and the realtime gateway alike): - `deck` and `usedCardIds` never reach any client; they become `deckSize`, `cardsRemaining` and `usedCardCount`. - `currentCard` reaches only the **explainer** and the players of the **other teams**. The explainer's teammates, the floater, spectators and a host who does not play get `null`. - `cardSeq` reaches everyone: it is how a host who cannot see the card names it when scoring (see [Stale presses](#stale-presses)). It counts deals and identifies no card. The panel never decides visibility; it shows what it was sent and words each viewer's job to match. ## Manifest `id: 'hushle'`, `version: '0.3.0'`, `type: 'game'`, the four permissions (`MANAGE_GAME_SESSION`, `MANAGE_SCORES`, `SEND_ROOM_MESSAGE`, `MANAGE_TIMER`), `locales` from the files in `locales/`, and a catalogue entry for 4–12 players (default 8) with spectators, a queue and `requiresVoiceRoom`. The catalogue summary is `catalog.summary` in the locale files. ## Custom words & server-local cards (status) Where words come from, and what is NOT wired yet (honest status, V4-009): - **Packs** live in `card_packs`/`cards` and are managed from **Admin → Plugins & Word Packs** (any BCP-47 language; built-in packs are immutable, Duplicate-to-custom to edit them). - **`server_local_cards`** (per-server extra words unioned into the deck) is **infrastructure only** today: the table, the language scope (migration 0026 — `NULL` = shared across all languages) and the loader filter exist, but there is **no creation API/UI** and **no backfill** for pre-0026 rows (they stay `NULL`/language-less, i.e. shared with every deck). Adding the "server-specific words" editor is future work; until then the feature is effectively dormant. ## Card decks `plugins/hushle/src/decks.ts` ships two decks of 24 cards (English and Turkish), each card with a difficulty (14 easy, 7 medium, 3 hard) and a category slug (`food-drink`, `places`, `nature`, …). The panel translates the built-in category slugs and shows any other category as written. ### DB-backed packs (M18) The structured seeds are exposed as `HUSHLE_BUILTIN_PACKS`: ```ts import { HUSHLE_BUILTIN_PACKS } from '@lobbyforge/hushle'; // → [ // { slug: 'hushle-en-basic', name: 'Hushle — English (Basic)', language: 'en', ... 24 cards }, // { slug: 'hushle-tr-basic', name: 'Hushle — Türkçe (Temel)', language: 'tr', ... 24 cards }, // ] ``` The host's `apps/web/lib/plugin-content-seeder.ts` runs the seeder (`seedBuiltinHushlePacks(db)`) on the first card-packs GET request after a fresh install. The seeder is idempotent and module-cached so a long-running server only does the work once. `plugins/hushle/src/builtInPacks.ts` is re-exported through a **subpath** (`@lobbyforge/hushle/builtInPacks`) so the seeder imports don't pull `postgres` (Node.js-only) into the client bundle. The main `@lobbyforge/hushle` entry point stays client-safe. `start-game` takes `packId` (a slug or a UUID); the host resolves the pack and injects its cards and language. The panel's lobby receives the server's packs as `cardPacks` (the host fetches `/api/servers/{id}/card-packs` while the session is in its lobby phase) and shows one tile per pack. Without packs — the fetch failed or the list is empty — it offers the two built-in languages instead and starts `hushle-en-basic` or `hushle-tr-basic`. ### Future card pack work - **Community packs** — a `POST /api/servers/{id}/card-packs` route for trusted users to upload a JSON pack; per-server enablement. - **Pack versioning** — the `slug` is currently the stable identifier. If packs ever need to evolve, add `version` to the schema and treat `(slug, version)` as the unique key. ## The panel `plugins/hushle/src/renderClient.tsx` exports `HushlePanel`, built from the activity UI kit (`@lobbyforge/plugin-sdk/ui`) to the "Calm Future" design (the Hushle and Plugin UI artboards). The phase views live in `src/ui/`: | File | What it holds | |---|---| | `renderClient.tsx` | The root: locale, `ActivityShell`, Hushle's stylesheet, the phase switch, the "this turn" log. | | `ui/lobby.tsx` | Host settings, the no-packs language fallback, how to play, the waiting screen. | | `ui/setup.tsx` | The room's people to seat by name, the two-team split, teams and open seats, the add-team form, the floater. | | `ui/playing.tsx` | A running turn (card column, host scoring, BUST), the pause between turns, who-sees-the-card, this turn, host tools. | | `ui/ended.tsx` | Winner (or tie), final scores, recap, new game. | | `ui/card.tsx` | The card face and the hidden-card state. | | `ui/scoreboard.tsx` | The team board: scores, players and their job this turn, the floater. | | `ui/shared.tsx` | The header, the view props, the host tap lock, "(you)". | | `ui/model.ts` | Pure view logic: roles, timer, next-up preview, standings, settings, action builders, the turn log. | | `ui/labels.ts`, `ui/i18n.tsx` | Values to translated words; the translator context and `Intl` helpers. | | `ui/theme.ts`, `ui/parts.tsx` | Difficulty colours and team tones; small generic pieces the kit does not ship (text field, choice tile, empty seat, visually hidden text, icons). | ### What each seat sees | Phase | Host | Explainer | Teammates | Other team | Floater | Spectator | |---|---|---|---|---|---|---| | Lobby | Settings, **Start Hushle** | — | — | — | — | Waiting, how to play | | Team setup | The room by name, **Split into two teams**, seat / unseat / floater, add a team, **Start first turn** | — | — | — | — | Teams, settings, who is not seated yet, waiting | | Turn | **Got it / Skip / Penalty** under the card (the card if the host is the explainer or on the other team), host tools, **End turn / End game** | The card; "the host scores each card" | "Listen to …" — no card | The card and **BUST** | "… is explaining" — no card | "… is explaining" — no card | | Time's up, or between turns | "Next up" and who explains, **Start next turn / End game** | Waiting | Waiting | Waiting | Waiting | Waiting | | Ended | Winner, scores, recap, **Start new game** | Winner, scores, recap | ← | ← | ← | ← | A host who plays also has that seat's view. A host on the other team gets **BUST** instead of **Penalty** — both cost the explaining team a point and draw the next card, and two red buttons doing the same thing would only make them hesitate. ### Layout - **Header** (every phase): the Hushle tile, who hosts and the deck count, the phase ("Turn 3", "Turn over", …), the timer while a turn runs, and — always in this spot — the host's controls for moving on: Start Hushle, Start first turn, End turn / Start next turn, End game, Start new game. - **Body**: a two-column grid (`Grid min={340}`) that stacks when the centre column is narrow — the card column (role line, card, the viewer's buttons) and the information column (team board, who sees the card, this turn, host tools). ### The card A coloured top bar and border by difficulty — easy blue, medium purple, hard red — with the difficulty's name and one, two or three pips in the top-right, so colour is never the only cue. A category chip, the word in large type, and the forbidden words struck through in red under "Don't say". The word and the forbidden list carry `lang={card.language}`: the pack's language may not be the panel's (an English pack in a Turkish app), and the tag is what makes uppercasing ("şişe" → "ŞİŞE") and screen-reader pronunciation right. Viewers without the card get a card-shaped placeholder that says what to do instead ("Listen to Mira"). ### Guards against double taps - **BUST** stays disabled after a press until a different card arrives (a bust always rotates the card), keyed on the card id. - The host's **Got it / Skip / Penalty / Next card** lock after a press until the next state arrives (keyed on a fingerprint of the card in play), and unlock by themselves after 4 s if the move never landed. - Each of those presses carries the `cardSeq` of the state the button was rendered from (`cardMoveIntent` in `ui/model.ts`), so a press that lands after the card changed is ignored ([Stale presses](#stale-presses)). - **Start first turn**, **End turn** and **Start next turn** carry the `turnNumber` they were rendered for (`turnMoveIntent`), so a double tap starts or ends one turn. - The server also de-duplicates retries by `actionId`. ### "This turn" The reducer keeps totals, not a history, so the panel keeps a short log by comparing each state with the one before: a counter of the explaining team moved (got it, skipped, bust — a host penalty and a bust are the same event), or a card was swapped without scoring. Words appear only for viewers who saw the card. The log lives in the viewer's browser; a reload starts it afresh and the scores stay the source of truth. ### Settings the lobby offers Only options `start-game` already takes: the pack, `turnDurationSeconds` (30, 45, 60, 90, 120 s — the whole turn's clock), `cardsPerTurn` (5, 10, 15, 20), `teamSize` (2–6) and `difficultyDistribution` through three presets — **Easier** (80 / 20 / 0), **Mixed** (60 / 30 / 10, the reducer's default) and **Harder** (20 / 40 / 40) — with the mix spelled out under the choice. **Start new game** on the end screen sends the finished game's settings again. ### Accessibility and theming Real buttons with visible labels; the icon-only one (removing a player) has an `aria-label`, and a button whose spoken name says more than its text ("Add Sam to Ice") still contains that text; `aria-pressed` on choices; the BUST button is described by its hint; the timer is a `role="timer"` with a spoken label; scores have spoken text ("7 points"); a polite live region announces each card's outcome. Every colour comes from the kit's `--lfui-*` variables or Hushle's own `--hushle-*` variables, with lighter-background text shades under `.lf-theme-light`. ## Voice-room integration The lobby's Activities hub (`apps/web/app/lobby/LobbyActivityView.tsx`) and the room page mount the panel through `PluginSurface`, which gives it its own component instance and tags it with the plugin's language: ```tsx ``` Other players receive each new state over the realtime gateway, or by polling every 5 s when it is unavailable. ## Activity-route flow `POST /api/servers/{id}/channels/{channelId}/activities` starts the session; the route creates a row in `game_sessions` with `pluginId = 'hushle'`, `status = 'lobby'`, and `state = createHushleInitialState()`. `POST /api/servers/{id}/activities/{sessionId}/actions` resolves the plugin via `getPlugin`, calls `plugin.handleAction(ctx, state, action)`, persists the new state via `setGameSessionState`, and writes an `activity.action` audit row with `metadata.actionType` carrying the reducer's action type. `POST /api/servers/{id}/activities/{sessionId}/end` ends the session (host or `START_ACTIVITY` only); Hushle's `end-game` action does not call this route — the reducer transitions to `ended` itself and the session stays open so the panel can show the final-score view. The lobby's **End** control (outside the panel) calls the `end` route when the activity is truly over. ## Tests `plugins/hushle/src/__tests__/`: - `hushle.test.ts` (56) — the reducer: every action, teams and floater, the per-team rotation (four turns for four players, the floater once for each team, an explainer picked by hand), the turn clock and time's up, weighted draw, BUST rules, stale presses (a BUST then the host's Got it for the same card applies once; two moderators score one card; a stale Skip, Penalty or Next card; deals counted across games; a turn on an empty deck scores nothing; End turn, Start next turn and Start first turn pressed twice move one turn), migrations up to v4. - `panel-model.test.ts` (27) — the panel's pure logic: each viewer's role against the projection, the deal and turn numbers every viewer sends, the deadline, the next-up preview checked against what `end-turn` really does over a full round, standings, settings round-tripped through the reducer, the two-team split, `set-teams` payloads, the "this turn" log over real reducer transitions. - `panel.test.tsx` (45) — the panel mounted in a DOM: every phase and seat, seating by name and splitting the room, time's up, the exact action each button dispatches (the host's card moves with their `cardSeq`, never the card id; the turn buttons with their `turnNumber`), the double-tap guards, Turkish rendering and "no raw key anywhere". `harness.tsx` holds the fixtures, a restatement of the server projection, and the DOM: the package does not depend on react-dom or a DOM library, so the harness borrows react-dom from `@lobbyforge/plugin-sdk` and happy-dom from `apps/web` (adding both as devDependencies would let it use plain imports). - `locales.test.ts` (8) — every key the panel uses exists, no key goes unused, Turkish matches English's placeholders. - `render-client.test.tsx` (2) — `renderClient` returns an element. `apps/web/e2e/activity-hushle.spec.ts` plays a short game through the real lobby UI with four browser contexts (see the file for how to run it); `compose-stack.spec.ts` covers the same rules through the API. ## State versioning `HUSHLE_STATE_VERSION` is `4`. `migrateHushleState(raw)` is wired as the plugin's `migrateState`, so the host upgrades any persisted row on read: version 0 rows gain `version`; version 1 rows gain the floater, `usedCardIds`, `teamSize`, the difficulty weights and a difficulty on every card; version 2 rows trade the single `currentExplainerIndex` for a rotation cursor on every team and a `turnNumber`, and a running turn gets its deadline (`endsAt` = start + duration). A game in progress keeps its explaining team's rotation going after whoever explains now; the other teams start theirs afresh. Version 3 rows gain `cardSeq: 0` — the same number on every read, so a panel that read the migrated row names the deal the reducer sees. Garbage falls back to the initial state. To change the shape: bump the constant, add a `migrateV4ToV5` step, and make the reducer produce the new version. ## Locales `plugins/hushle/locales/.json` — flat keys prefixed `hushle.`, plus `catalog.summary`. English and Turkish are complete. `pnpm i18n:sync` regenerates `src/locales.generated.ts`; `pnpm i18n:add ` scaffolds a new language. Keys are written out in full in the panel's files (`t('hushle.lobby.packLabel')`), because the locales test finds them by reading those files. Counts use plurals. See [TRANSLATING.md](TRANSLATING.md). ## Known gaps - **No pause.** `timer.paused` exists but no action sets it, so the panel has no pause/resume control. - **No "ready".** Players have no ready state; the lobby and team setup show who is seated instead. - **Seating mid-game.** `set-teams` works only before play, so someone who joins the voice room during a game watches until the next one. Fixed in state version 3: every player now explains in turn (each team has its own rotation), the floater explains for both teams, and the turn timer runs once per turn instead of restarting on every card. ## Reference - `plugins/hushle/src/state.ts` — types, `createHushleInitialState`, `migrateHushleState`. - `plugins/hushle/src/decks.ts` — the bundled English and Turkish decks. - `plugins/hushle/src/actions.ts` — the reducer, `hushleExplainerQueue` and `hushleNextExplainerForTeam`. - `plugins/hushle/src/rotation.ts` — the explainer rotation. - `plugins/hushle/src/renderClient.tsx` and `src/ui/` — the panel. - `plugins/hushle/src/index.ts` — the `hushlePlugin` registry entry. - `plugins/hushle/locales/{en,tr}.json` — UI strings. - `packages/core/src/activity-projection.ts` — who sees the card. - `apps/web/lib/prepare-plugin-action.ts` — the pack → deck injection. - `apps/web/app/lobby/LobbyActivityView.tsx`, `apps/web/app/room/PluginSurface.tsx` — where the panel mounts. - `projectdetails/12_HUSHLE_PLUGIN.md` — the product spec.