diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9a9d99a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,34 @@ +name: Bug report +description: Something doesn't work as expected +labels: [bug] +body: + - type: dropdown + id: area + attributes: + label: Where + options: [TV app (Vega), Phone app, Server, AI suspects / voices, A case's content, Docs] + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: "1. Create a room on the TV\n2. Join from a phone\n3. …" + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected vs actual + validations: + required: true + - type: input + id: env + attributes: + label: Environment + placeholder: "Fire TV Stick 4K Max / Vega Virtual Device; phone browser; commit" + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Please remove any API keys. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..09ab370 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security issue + url: https://github.com/perhaps-aksh/RoomRiddle/security/advisories/new + about: Please report vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..89ff465 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,18 @@ +name: Feature or case idea +description: Suggest a feature, an improvement or a new mystery +labels: [enhancement] +body: + - type: textarea + id: idea + attributes: + label: The idea + validations: + required: true + - type: textarea + id: why + attributes: + label: Who it helps and why + - type: textarea + id: notes + attributes: + label: Anything else (sketches, references) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..49328bd --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,15 @@ +## What + + +## How + + +## Checks +- [ ] `npm run check` passes +- [ ] TV changes: `apps/tv/scripts/wsl-check.sh` passes (and checked on the Vega Virtual Device if it changes what's on screen) +- [ ] No case-specific logic in the engine or UI; content stays in `packages/mystery-data` +- [ ] No secrets, keys or `.env` files +- [ ] Docs updated (README, `docs/`, friction log) where relevant + +## Screenshots + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..feece30 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + name: Typecheck and test (engine, content, server, phone) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check + env: + AI_DISABLED: "1" + VOICES_DISABLED: "1" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..69057e3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +All notable changes, newest first. The project follows [Conventional Commits](https://www.conventionalcommits.org/); see `git log` for every commit. + +## [Unreleased]: public release prep +- **Brand:** the app is now "Room Riddle", with a launcher icon on the TV and a favicon and web manifest on the phone. +- **Docs:** showcase README with screenshots; technical reference moved to `docs/system-design.md`; contributing guide, code of conduct, security policy, credits, `.env.example`, CI and issue/PR templates. + +## 2026-10-12: Voices on the TV +- AI suspect voices play on Vega: the server converts clips to fragmented-MP4 AAC and the TV streams them through Media Source (Vega refuses plain `http://` media; FL-014). +- The speech bubble sits above the footer. + +## 2026-10-10: AI suspects and five cases +- **AI questioning (server):** prompts built only from unlocked truth-table facts, a reply guard for spoilers, answers and character breaks, authored fallbacks, a question budget, cooldowns and caching. Groq first. +- **Voices:** free Edge neural voices, with Groq Orpheus as a second provider; one voice per character. +- **Phone Question tab:** pick a suspect, ask in your own words, show found evidence. +- **TV speech bubble:** "is thinking…", then the reply with voice and subtitles. +- **Content:** truth tables; *The Hollow Manor*, *Signal Lost*, *The Forgotten Temple* and *The Yaksha's Three Questions* are playable; the library fits five cases. +- Technical README and market research. + +## 2026-10-06 to 10-09: Audio and polish (Phase 7) +- Data-driven audio: music states, ambience layers, cues and voice ducking; pooled effect players; saved settings. +- Audio diagnostics; the emulator crackle was traced to the emulator itself (FL-013). +- World fixes: real clock faces, focus tags, remote focus that follows the layout; Play Again in a room. + +## 2026-09-29 to 10-05: Design system and 2.5D world (Phases 5 and 6) +- Design tokens shared by TV and phone; redesigned TV and phone screens; connection overlays; reduced motion. +- 2.5D world: asset registry, camera and parallax, director and timelines, rain, fog, steam, lightning, the train and the detective. +- Vega WebSocket messages that arrive in pieces are reassembled (FL-011). + +## 2026-09-27 to 09-29: Foundation (Phases 0 to 4) +- Monorepo, development rules, Vega app generated and running on the Vega Virtual Device. +- TV shell with D-pad navigation. +- Deterministic game engine and *The Last Train*, playable end to end. +- Authoritative WebSocket server: rooms, QR join, reconnection, host pause. Phone web app and a shared protocol client. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..1d7f82d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,7 @@ +# Code of Conduct + +Room Riddle follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +In short: be welcoming and respectful, assume good intent, give and accept constructive feedback gracefully, and keep discussions about the work. Harassment, insults, and personal or political attacks are not accepted. + +To report unacceptable behaviour, open a private [security advisory](https://github.com/perhaps-aksh/RoomRiddle/security/advisories/new) or contact the maintainer through their GitHub profile. Reports are handled confidentially. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..14d7d06 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,44 @@ +# Contributing to Room Riddle + +Thanks for your interest. Room Riddle started as a hackathon project and welcomes issues, fixes and new cases. + +## Ground rules +Read [AGENTS.md](AGENTS.md) first: it is the rulebook for humans and coding agents alike. The short version: + +- **The server is authoritative.** Clients send intents ("my answer is 1147"), never results. +- **The engine is pure and mystery-agnostic.** No I/O, no `Date.now()`, no case ids in engine or UI code. +- **Content is data.** Everything about a case lives in `packages/mystery-data`. +- **The TV works with a remote only.** Every focusable element has a visible focus state; Back never destroys progress without asking. +- **AI is optional and guarded.** It may only voice facts the room has unlocked, and the game must play without it. +- **No secrets in git.** Keys go in `apps/server/.env` (see `.env.example`). + +## Setup +```bash +npm install +npm run check # must pass before every commit +npm run serve # server + phone app on :4000 +``` +TV app setup (Vega SDK, WSL2 on Windows): [docs/development.md](docs/development.md). + +## Workflow +1. Open an issue describing the bug or idea (templates are provided). +2. Branch from `main`: `feat/…`, `fix/…`, `docs/…`. +3. Keep changes small and tested. Add tests next to the code (`*.test.ts`, or `apps/tv/test/*.spec.tsx`). +4. Run `npm run check` (and `apps/tv/scripts/wsl-check.sh` for TV changes). +5. Commit with [Conventional Commits](https://www.conventionalcommits.org/): `feat(tv): …`, `fix(server): …`, `docs: …`. +6. Open a pull request and fill in the template. CI must be green. + +## Writing a new case +1. Copy an existing case folder in `packages/mystery-data/src/` (for example `the-forgotten-temple/`). +2. Fill in the `MysteryDefinition`: summary and audience, intro, scenes, clues, puzzles (each with accepted answers and exactly three hints), endings, scoring. +3. Add the **truth table**: each character's persona, public claims and secrets with `unlockedBy`, plus guarded spoiler terms. +4. Register it in `packages/mystery-data/src/index.ts`. +5. Run `npm run check`. The catalog tests validate every reference and **play the case to the end automatically**; they fail if a clue can't be found or a spoiler can never unlock. + +Cultural or religious source material must be original, respectful, and never presented as scripture; worship is never a puzzle. + +## Reporting Vega / Fire TV friction +Real problems with the Vega SDK, the emulator or tooling go in [docs/friction-log.md](docs/friction-log.md), with steps, expected and actual results, and the workaround. Only verified facts. + +## Code of conduct +This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). diff --git a/CREDITS.md b/CREDITS.md new file mode 100644 index 0000000..f095ae0 --- /dev/null +++ b/CREDITS.md @@ -0,0 +1,24 @@ +# Credits + +**Design, code and story:** Chitraksh Kumar ([@perhaps-aksh](https://github.com/perhaps-aksh)). + +## Art +- App icon, banner and concept art: AI-assisted illustration, art-directed and edited by the author. Released with the project under the MIT licence. +- TV scenes in the current build use code-drawn placeholder shapes; painted plates and character art are being added through the asset system. +- Screenshots in `docs/images` were captured from the Vega Virtual Device and a phone browser. + +## Audio +- Current music, ambience and effects are **placeholders synthesized for development** (`apps/tv/scripts/gen-placeholder-audio.py`). They will be replaced by licensed or CC0 recordings, which will be listed here with their sources. +- Detective narration lines: Microsoft Edge neural voice `en-GB-RyanNeural`, generated for development. +- Live suspect voices are generated at runtime by Microsoft Edge neural TTS (via [`msedge-tts`](https://www.npmjs.com/package/msedge-tts)) and Groq-hosted [Orpheus](https://console.groq.com/docs/text-to-speech) by Canopy Labs. + +## Services and libraries +- [Amazon Vega SDK](https://developer.amazon.com/docs/vega/) and React Native for Vega. +- [Groq](https://groq.com/) free tier: `openai/gpt-oss-120b` (OpenAI open-weight model) for suspect dialogue. +- [`ws`](https://github.com/websockets/ws), [esbuild](https://esbuild.github.io/), [tsx](https://github.com/privatenumber/tsx), [ffmpeg](https://ffmpeg.org/) and the React Native Testing Library. + +## Fonts +The TV uses Vega's built-in system fonts (Bookerly, Amazon Ember, Amazon Ember Mono); none are bundled. + +## Inspiration +*The Yaksha's Three Questions* is inspired by the *Yaksha Prashna* episode of the Mahabharata. The case is an original story and puzzles, not a retelling of scripture. diff --git a/README.md b/README.md index c8b8bf8..cb5efc3 100644 --- a/README.md +++ b/README.md @@ -1,632 +1,265 @@ -# RoomRiddle - -**Gather. Investigate. Unravel the Mystery.** - -RoomRiddle is a cinematic, cooperative mystery game for **Amazon Fire TV on Vega OS**. The TV is the crime scene and the game master. Phones are each player's private notebook and controller. An authoritative server keeps everyone in one synchronized investigation, a deterministic engine decides every answer, and AI-voiced suspects answer questions, but only with what the room has earned. - -> Built for the *Build, Ship, Shape: Amazon Developer Hackathon*, Fire TV track (family entertainment, multi-modal UX). - ---- - -## Contents -1. [What it does](#1-what-it-does) -2. [Status](#2-status) -3. [System architecture](#3-system-architecture) -4. [Repository layout](#4-repository-layout) -5. [The game engine](#5-the-game-engine) -6. [Mystery content model and truth tables](#6-mystery-content-model-and-truth-tables) -7. [Multiplayer](#7-multiplayer) -8. [TV app on Vega OS](#8-tv-app-on-vega-os) -9. [Audio system](#9-audio-system) -10. [AI layer](#10-ai-layer) -11. [Expandable mystery library](#11-expandable-mystery-library) -12. [Security and privacy](#12-security-and-privacy) -13. [Testing and quality](#13-testing-and-quality) -14. [Setup and running](#14-setup-and-running) -15. [Performance](#15-performance) -16. [Known limitations](#16-known-limitations) -17. [Documentation index](#17-documentation-index) +

+ Room Riddle: Gather. Investigate. Unravel the Mystery. +

+ +

+ Room Riddle app icon +

+ +

A cinematic, cooperative mystery game for Amazon Fire TV.
The TV is the crime scene. Your phone is your notebook. The suspects talk back.

+ +

+ License: MIT + Platform: Fire TV / Vega OS + React Native for Vega + TypeScript + Node.js + CI +

+ +

+ How it plays · + The cases · + AI suspects · + Quick start · + Architecture · + Docs +

+ +> Built for the **Build, Ship, Shape: Amazon Developer Hackathon** (Fire TV track: family entertainment, multi-modal UX). --- -## 1. What it does +## ✦ How it plays -1. The TV opens a room and shows a 4-digit code and a QR code. -2. Players scan it with their phones. No app install, no account. -3. The case opens on the TV: a cinematic scene (for example, a storm-lashed railway platform at 11:52 PM). -4. Players explore with the remote or their phones, inspect objects, discover evidence and solve puzzles together. -5. Players question suspects. Each suspect answers in character, but can only reveal what the room's evidence allows. -6. The group submits its final theory. The engine decides which ending the evidence supports. +

+ A suspect answers on the TV while the player questions him from a phone +
Asking Inspector Voss a question from a phone. He answers on the TV, in character and in his own voice. +

-Five cases are designed: +1. **Open a room on the TV.** It shows a 4-digit code and a QR code. +2. **Everyone scans it with a phone.** No app to install, no account: a nickname and you're in. +3. **The case opens on the big screen.** A storm-lashed railway platform at 11:52 PM, a conductor missing, a train that leaves at midnight. +4. **Investigate together.** Explore the scene with the remote, examine evidence on your phones, crack puzzles, and spend hints as a team. +5. **Question the suspects.** Type anything. They answer on the TV, but they only know what your group has earned, and some of them lie. +6. **Name the truth.** The game decides which ending your evidence supports. -| Case | Audience | Length | Mechanics | -|---|---|---|---| -| The Last Train | Teens & adults | 15–25 min | Time code, ticket reconstruction, ledger who/where/when, final theory | -| The Hollow Manor | Teens & adults | 20–30 min | Locked-room evidence, floor-plan alignment, catalogue comparison, final theory | -| Signal Lost | Teens & adults | 20–30 min | Transcript, clock correction, cause analysis, final theory | -| The Forgotten Temple | Family, 8+ | 15–25 min | Symbol matching, sequence, location, preservation choice | -| The Yaksha's Three Questions | All ages | 10–20 min | Reasoning questions, cooperation, symbol order (untimed, no penalties) | +| | | +|:---:|:---:| +| Home screen | The case library | +| **Home:** create or join a room with the remote | **Library:** five complete cases | +| Room lobby with QR code | The 2.5D station scene | +| **Lobby:** scan the QR code, see who's in | **The world:** a layered 2.5D scene you explore with the remote | ---- +### Your phone is your notebook -## 2. Status +

+ Phone screens: join, scene evidence, suspect questioning, puzzle with hints +
Join in seconds · examine evidence · question suspects · solve puzzles with three-step hints +

-As of 2026-10-10. "Verified" means covered by automated tests and run on the Vega Virtual Device (VVD). - -| Area | Status | -|---|---| -| Deterministic game engine (scenes, clues, puzzles, hints, scoring, timers, endings, replay) | **Implemented, verified** | -| Authoritative WebSocket server: rooms, QR join, roles, reconnection, host pause | **Implemented, verified** | -| Phone companion (web, no install) | **Implemented, verified** (UI polish pending) | -| TV app on Vega OS: D-pad focus, case-file screens, lobby, settings | **Implemented, verified on VVD** | -| 2.5D cinematic world for The Last Train (parallax, camera, cinematics, dialogue) | **Implemented, verified on VVD** (placeholder art) | -| Data-driven audio (music states, ambience layers, cues, voice ducking) | **Implemented, verified** (placeholder sounds; emulator audio crackles, see FL-013) | -| The Last Train: full playable case | **Implemented, verified** | -| Truth tables (per-character facts, spoiler guards), server-only | **In progress** | -| Cases 2–5 as playable data | **In progress** | -| AI suspect interrogation (Groq / Gemini, guarded, authored fallback) | **Server done** (Day 2); phone and TV UI next | -| Phone Question tab | **Done** (Day 3) | -| Character voices (free Edge neural TTS, cached, served at `/voice/.mp3`) | **Server done** (Day 3); TV playback next | -| AI adaptive hints, narration | Planned | -| Painted scene art, video loops, real audio | **In progress** | -| Physical Fire TV hardware validation | **Planned** (device being acquired) | -| Downloadable mystery library / marketplace | **Planned** (architecture below) | +> **Art note:** the TV scenes in these screenshots use the built-in placeholder art. The painted art pass (illustrated plates, character portraits, video loops) goes in through the asset system without code changes; see [Status](#-status). --- -## 3. System architecture - -### 3.1 Context - -```mermaid -flowchart LR - subgraph Home["Living room"] - TV["Fire TV (Vega OS)
RoomRiddle TV app
React Native for Vega"] - P1["Phone 1
browser"] - P2["Phone 2
browser"] - P3["Phone N
browser"] - end - subgraph Server["RoomRiddle server (Node.js + TypeScript)"] - WS["WebSocket gateway
/ws"] - HTTP["HTTP
/health, phone app, /join/:code"] - RM["Rooms + sessions"] - GS["GameSession"] - ENG["Game engine
(pure, deterministic)"] - MD["Mystery data
+ truth tables"] - AI["AI gateway
(guarded)"] - end - LLM1["Gemini API
(free tier)"] - LLM2["Groq API
(fallback)"] - - TV <-->|"intents / snapshots"| WS - P1 <--> WS - P2 <--> WS - P3 <--> WS - P1 -->|"scan QR"| HTTP - WS --> RM --> GS --> ENG --> MD - GS --> AI - AI -.-> LLM1 - AI -.-> LLM2 -``` - -Dashed edges are the AI layer (in progress). Everything else is implemented. - -### 3.2 Layers - -```mermaid -flowchart TB - subgraph Presentation - TVUI["apps/tv
screens, 2.5D world, audio renderer"] - PH["apps/player
phone DOM app"] - end - subgraph Contracts - ST["packages/shared-types
protocol, game, mystery, world, audio"] - CL["packages/client
RoomClient, reducer, copy"] - UI["packages/ui
design tokens"] - end - subgraph Logic - EN["packages/game-engine
reducer, validation, scoring, truth gates"] - SC["packages/scene
camera, director, layout, AudioDirector"] - end - subgraph Content - MD["packages/mystery-data
cases, worlds, audio direction, truth tables"] - end - SV["apps/server
authority"] - - TVUI --> CL & UI & SC & ST - PH --> CL & UI & ST - SV --> EN & MD & ST - EN --> ST - SC --> ST - MD --> ST -``` - -Rules: -- **Data flows one way:** `Mystery Data → Game Engine → GameState → UI`. -- **Clients send intents, never results.** A phone sends "my answer is 1147", never "I solved it". -- **The engine is mystery-agnostic.** No case id appears in engine or UI code; everything specific is data. -- **The engine does no I/O and never reads a clock.** Time is passed in, so any game replays from its action log. -- **Presentation is not logic.** The 2.5D world and the audio system react to `GameState`; they never decide anything. +## ✦ The cases ---- +Every case is a data file: scenes, clues, puzzles, three-level hints, endings and a truth table for every character. The engine knows nothing about any specific mystery. -## 4. Repository layout +| Case | Mood | Audience | Length | You'll be doing | +|---|---|---|---|---| +| 🚂 **The Last Train** | Noir thriller, 1930s railway | Teens & adults | 15–25 min | Reading clocks that lie, rebuilding a torn ticket, working out who, where and when from a ledger | +| 🕯️ **The Hollow Manor** | Gothic locked room | Teens & adults | 20–30 min | Locked-room evidence, lining up floor plans, comparing a catalogue | +| 📡 **Signal Lost** | Tech mystery | Teens & adults | 20–30 min | Transcripts, clock-drift correction, root-cause analysis | +| 🛕 **The Forgotten Temple** | Family adventure | Family, 8+ | 15–25 min | Symbol matching, sequences, architecture and history; ends with a preservation choice | +| 🌊 **The Yaksha's Three Questions** | Gentle riddle tale | All ages | 10–20 min | Reasoning riddles, cooperation, symbol order (untimed, no penalties) | -``` -apps/ - tv/ Vega OS TV app (React Native for Vega 0.83, TypeScript). Not an npm workspace (Vega SDK is Linux/macOS). - player/ Phone companion: plain TypeScript + DOM, bundled by esbuild, served by the server. - server/ Authoritative game server: HTTP + WebSocket (ws), rooms, sessions, QR, AI gateway. -packages/ - shared-types/ Single source of truth: protocol, game state, mystery schema, world and audio types. - game-engine/ Pure deterministic rules: applyAction(mystery, state, action) → {state, events}. - mystery-data/ Authored cases: scenes, clues, puzzles, hints, endings, worlds, audio direction, truth tables. - scene/ Framework-free presentation logic: camera, director, layout, assets, AudioDirector. - client/ Framework-free protocol client shared by TV and phone. - ui/ Design tokens (colour, type, spacing, motion) for TV and phone. -docs/ Architecture, development, game design, testing, friction log, research. -``` +*The Yaksha's Three Questions* is inspired by the *Yaksha Prashna* from the Mahabharata. Its puzzles and story are original, and it is not presented as scripture. --- -## 5. The game engine +## ✦ AI suspects that can't spoil the game -`packages/game-engine` is a pure reducer. +The AI is an **actor, not a judge**. A pure, deterministic game engine decides every outcome. The AI only gives the characters a voice, and only from facts the room has unlocked. ```mermaid flowchart LR - A["GameAction
(intent + time)"] --> R{"applyAction"} - S0["GameState n"] --> R - M["MysteryDefinition"] --> R - R --> S1["GameState n+1"] - R --> E["GameEvents
(feedback only)"] - R -->|"invalid"| X["same state +
'rejected' event"] + Q["📱 'Where were you
at 11:47?'"] --> B["Budget, cooldown,
length checks"] + B --> F["Truth table:
only unlocked facts"] + F --> M["Free LLM
(Groq gpt-oss-120b)"] + M --> G{"Guard:
spoilers? answers?
out of character?"} + G -- safe --> V["🔊 Voice on the TV
+ subtitles"] + G -- unsafe / offline --> A["Authored fallback line"] --> V ``` -- **Actions:** `START_MYSTERY`, `ADVANCE_SCENE`, `DISCOVER_CLUE`, `OPEN_PUZZLE`, `CLOSE_PUZZLE`, `SUBMIT_ANSWER`, `REQUEST_HINT`, `MAKE_CHOICE`, `PAUSE`, `RESUME`, `TICK`, `COMPLETE_MYSTERY`. -- **Invalid actions never throw.** They return the unchanged state plus a `rejected` event, so a stale or duplicated phone message is harmless. -- **Answer validation is exact.** Input kinds: `digits` (`11:47` = `11 47` = `1147`), `choice`, `combination` (one option per field, also used for ordering and matching) and `text` (case, spacing and punctuation folded, then exact match). No fuzzy matching. -- **Scoring, hints, timers and endings are data.** Hints are three authored levels (nudge, connect, explain). Endings are ordered condition lists; the first match wins. -- **State is plain JSON** with a schema version: saved, broadcast, and rebuilt with `replay(mystery, actions)`. +- **Locked facts never reach the model.** The prompt holds only the persona, the unlocked facts and the clues already found. +- **Every reply is checked** for guarded spoilers ("lever 7"), puzzle answers and out-of-character text before anyone sees it. Failures are replaced by authored lines. +- **Evidence matters.** Show a suspect the right clue and the secrets it unlocks become speakable, so their story can change. +- **Free for players.** Free-tier models only, with a per-room question budget, cooldowns and caching. Voices are free neural TTS (Microsoft Edge voices, Groq Orpheus as backup), one fixed voice per character. +- **Optional.** With no keys, or `AI_DISABLED=1`, every case is still fully playable. -```mermaid -stateDiagram-v2 - [*] --> Lobby - Lobby --> Intro: START_GAME (host) - Intro --> Scene: ADVANCE_SCENE - Scene --> Scene: DISCOVER_CLUE - Scene --> Puzzle: requirements met - Puzzle --> Puzzle: wrong answer / hint - Puzzle --> Scene: solved (next unlocks) - Scene --> NextScene: ADVANCE_SCENE (scene complete) - NextScene --> Scene - Puzzle --> Ending: final puzzle solved - Scene --> Ending: timer expired / too many attempts - Ending --> Lobby: replay -``` +

Inspector Voss answering on the TV

--- -## 6. Mystery content model and truth tables +## ✦ Features -Every case is a `MysteryDefinition` (TypeScript data in `packages/mystery-data//`): +**On the TV (Vega OS)** +- Remote-first, 10-foot UI with a visible focus frame, readable from the couch; Back never throws progress away without asking. +- Cinematic **2.5D world**: depth-layered scenes, a moving camera, rain, fog, steam and lightning, dialogue and an arrival cinematic. +- A data-driven **audio director**: music states with crossfades, layered ambience, effects, and voice with music ducking. +- Two presentation modes: the cinematic world, or case-file scenes for lower-powered devices and quicker play. +- Settings for volume per channel, subtitles and reduced motion, saved across launches. -```mermaid -classDiagram - class MysteryDefinition { - summary - audience - intro - startSceneId - scenes[] - clues[] - puzzles[] - choices[] - endings[] - scoring - failure - truth (server-only) - } - class Scene { id; narration; clueIds; puzzleIds; nextSceneId } - class Clue { id; sceneId; prompt; description; major; requires } - class Puzzle { id; sceneId; input; acceptedAnswers; hints[3]; requires; solvedText; completesMystery } - class Ending { id; tone; when[]; narration } - class MysteryTruth { narratorId; solution; characters[]; guarded[]; offLimits } - class CharacterTruth { id; name; role; persona; interviewable; facts[] } - class TruthFact { id; kind; false; text; unlockedBy } - class GuardedTerm { terms[]; until } - MysteryDefinition --> Scene - MysteryDefinition --> Clue - MysteryDefinition --> Puzzle - MysteryDefinition --> Ending - MysteryDefinition --> MysteryTruth - MysteryTruth --> CharacterTruth - CharacterTruth --> TruthFact - MysteryTruth --> GuardedTerm -``` - -**Truth tables** are the authored ground truth for each character: -- a **claim** is what they say publicly (and may be flagged false); -- a **secret** is true but only becomes speakable once its `unlockedBy` requirement (clues found, puzzles solved) holds; -- **guarded terms** are spoilers ("11:47", "lever 7") that no voice may say until the room has earned them. +**On the phone (web)** +- Join by QR code; each phone is a private notebook with Scene, Evidence and Question tabs. +- Puzzles with exact answer checking (`11:47` = `11 47` = `1147`) and three authored hint levels. +- Live room state: score, time, who found what, and a toast when someone else makes a discovery. +- Survives a dropped connection: you reconnect into the same seat with the current state. -The engine exposes pure gates over them: `speakableFacts`, `lockedFacts`, `activeGuardedTerms`, `spoilersIn`. The truth table is stripped from everything sent to clients (`toPublicMystery`), and game rules never read it. - -`validateMysteryDefinition` runs in tests for every playable case. It fails the build on unreachable scenes, unknown ids, malformed answers, puzzles that need clues from later scenes, broken truth-table references, secrets that are never locked, and guarded terms that can never unlock. +**On the server** +- **Authoritative:** clients send intents ("my answer is 1147"), never results. The server runs the engine and broadcasts the new state. +- Rooms for 2–6 players, host pause when the TV drops, rate limits, frame validation, and no accounts or personal data. +- Answer keys, hint text and truth tables never reach a client before they are earned. --- -## 7. Multiplayer - -### 7.1 Server components - -```mermaid -flowchart LR - TV["TV (host)"] -- ws --> GW - PH1["Phone"] -- ws --> GW - PH2["Phone"] -- ws --> GW - GW["websocket/server
messages.ts (validate), router"] --> RMG["RoomManager"] - GW --> SM["SessionManager
(tokens)"] - GW --> CM["ConnectionManager
(sockets ↔ seats, broadcast)"] - RMG --> ROOM["Room"] - ROOM --> PM["PlayerManager"] - ROOM --> GSN["GameSession"] - GSN --> ENG["game-engine.applyAction"] -``` - -### 7.2 Protocol - -`packages/shared-types/src/protocol.ts` is the single source of truth. - -| Direction | Messages | -|---|---| -| Client → server | `CREATE_ROOM`, `JOIN_ROOM`, `RECONNECT`, `LEAVE_ROOM`, `READY`, `START_GAME`, `DISCOVER_CLUE`, `OPEN_PUZZLE`, `CLOSE_PUZZLE`, `SUBMIT_ANSWER`, `REQUEST_HINT`, `ADVANCE_SCENE`, `MAKE_CHOICE` | -| Server → client | `ROOM_CREATED`, `ROOM_JOINED`, `RECONNECT_SUCCESS`, `ROOM_STATE`, `PLAYER_JOINED/UPDATED/LEFT`, `GAME_STARTED`, `GAME_STATE_UPDATED`, `GAME_COMPLETED`, `LEFT_ROOM`, `ROOM_CLOSED`, `ERROR` | - -Every server message carries `serverTime`; replies echo the request's `requestId`. +## ✦ Quick start -### 7.3 A turn, end to end +**Requirements:** Node.js 22 or newer, and npm 10 or newer. The TV app also needs the [Vega SDK](https://developer.amazon.com/docs/vega/) on Ubuntu or macOS; it runs on Windows through WSL2, see [docs/development.md](docs/development.md). -```mermaid -sequenceDiagram - autonumber - participant P as Phone (Aksh) - participant S as Server - participant E as Game engine - participant T as TV - participant Q as Other phones - P->>S: SUBMIT_ANSWER {station-clock, "11:47"} - S->>S: validate frame, rate limit, seat - S->>E: applyAction(mystery, state, {SUBMIT_ANSWER, at: now}) - E-->>S: state', events [answer-correct, puzzle-unlocked] - S->>T: GAME_STATE_UPDATED {gameState, update: {actorName, events}, reveals} - S->>Q: GAME_STATE_UPDATED - S->>P: GAME_STATE_UPDATED - T->>T: world reacts: detective line, discovery sound, camera move -``` - -### 7.4 Joining and reconnection - -```mermaid -sequenceDiagram - participant T as TV - participant S as Server - participant P as Phone - T->>S: CREATE_ROOM {mysteryId} - S-->>T: ROOM_CREATED {code, QR matrix, session} - P->>S: GET /join/:code (scan QR) - S-->>P: phone app - P->>S: JOIN_ROOM {code, "Aksh"} - S-->>P: ROOM_JOINED {room, session token, PublicMystery} - S-->>T: PLAYER_JOINED - Note over P,S: Wi-Fi drops - P--xS: socket closed (seat kept, connected=false) - P->>S: RECONNECT {playerId, sessionToken} - S-->>P: RECONNECT_SUCCESS (full current state, same seat) +```bash +git clone https://github.com/perhaps-aksh/RoomRiddle.git +cd RoomRiddle +npm install +npm run check # typecheck + all package, server and phone tests +npm run serve # build the phone app and start the server on :4000 ``` -- Room codes: 4 random digits (`crypto.randomInt`), unique among live rooms. -- Session tokens: random 32 bytes, compared in constant time; a newer connection for a seat closes the older one. -- If the **host TV** drops, the game pauses (the clock stops) and phones show "waiting for the TV". A room whose host is gone for 10 minutes closes. -- Limits: 2–6 phone players, 200 rooms, per-connection rate limit, 10 s heartbeats. State is in memory by design. -- Vega's WebSocket client can split one message across several `onmessage` events (FL-011); `RoomClient` reassembles them. - ---- +**Optional AI and voices:** copy `apps/server/.env.example` to `apps/server/.env` and add a free [Groq API key](https://console.groq.com/keys). Never commit `.env`. Without a key, suspects use authored lines. Spoken lines also need [ffmpeg](https://ffmpeg.org/) on the PATH (or `FFMPEG_PATH`); without it the TV shows subtitles only. -## 8. TV app on Vega OS +**TV app** (inside Ubuntu or WSL2 with the Vega SDK): -### 8.1 Platform features used - -| Vega / Fire TV capability | How RoomRiddle uses it | -|---|---| -| React Native for Vega (RN 0.83) | The whole TV app | -| D-pad focus management (preferred focus, explicit neighbours, focus guides) | Every screen is remote-driven, with a focus frame readable from the couch | -| Back key handling | Back never destroys a room or progress without confirmation | -| W3C media (`@amazon-devices/react-native-w3cmedia`) | Music, ambience, effects and voice through one data-driven backend | -| App lifecycle (foreground/background) | Audio mutes in the background and resumes on return | -| Async storage | Settings persist across launches | -| System fonts (Bookerly, Amazon Ember, Ember Mono) | Three type voices, no bundled fonts | -| Vega Virtual Device + CLI | Scripted device QA (remote keys + screenshots) | -| Planned: Content Launcher / deep links | Open RoomRiddle straight into a case | - -### 8.2 Screens and modes - -```mermaid -flowchart LR - Home --> Mysteries["Mystery library"] --> Details --> Intro - Home --> Create["Create room
(code + QR + lobby)"] --> Intro - Home --> Settings --> Diag["Audio diagnostics (dev)"] - Intro --> World["2.5D world
(cinematic setting)"] - Intro --> CaseFile["Case-file scenes
(casefile setting)"] - World --> Inspect["Inspect object"] --> World - World --> Evidence & Puzzle - CaseFile --> Evidence & Puzzle - Puzzle --> World - World --> Ending - CaseFile --> Ending +```bash +bash apps/tv/scripts/wsl-check.sh # typecheck + lint + 75 Jest tests +bash apps/tv/scripts/wsl-build.sh # build the .vpkg +bash apps/tv/scripts/wsl-install.sh # install and launch on the Vega Virtual Device ``` -### 8.3 The 2.5D cinematic world +- On the Vega Virtual Device the TV reaches the server through `apps/tv/scripts/vvd-bridge.js`. +- On a Fire TV device, point `apps/tv/src/config/server.ts` at `ws://:4000/ws`. -2D art in depth planes with a camera, not a 3D game, so it stays smooth on a streaming stick. - -```mermaid -flowchart TB - WD["mystery-data/<case>/world
manifest, sets, shots, cinematics, dialogue"] --> DIR - subgraph scene["packages/scene (pure, tested)"] - DIR["Director
modes, timeline, dialogue, characters, fades"] - CAM["Camera
shots, moves, shake, lookShot, occluders"] - LAY["Layout
objects on planes"] - AST["Asset registry
final → fallback → placeholder"] - INT["Interactions
object → engine GameAction"] - end - DIR --> STAGE - CAM --> STAGE - LAY --> STAGE - AST --> STAGE - STAGE["apps/tv/world
Stage, AssetView, Actors, Effects (rain, fog, steam, lightning)"] - INT -->|"DISCOVER_CLUE"| GP["GameProvider → server"] - GP -->|"GameState"| DIFF["diffEvents → reactions
(dialogue, sound, camera)"] - DIFF --> DIR -``` - -- **Parallax is one rule:** translate = −camera × depth. -- **Assets are swappable by id.** Replacing a placeholder with final art (painted plate, cut-out layer, MP4 loop) only changes the asset map. -- **The world reacts to authoritative state, not to its own input,** so a clue found on a phone produces the same reaction on the TV. Reactions queue behind cinematics. +**No TV handy?** Run `npm run fake-tv -w @roomriddle/server` to host a headless room, then open the printed join link on your phone or in a browser. `npm run play-bots -w @roomriddle/server -- ` plays a scripted game alongside you. --- -## 9. Audio system +## ✦ Architecture ```mermaid flowchart LR - subgraph Data["mystery-data"] - MAN["Audio manifests
(id → file, channel, volume, loop)"] - DIRN["Audio direction
music states, ambience per set, cues, moments"] - end - subgraph Logic["packages/scene"] - AD["AudioDirector
music state machine, crossfades,
ambient layers, voice ducking (music to 40%)"] + subgraph Home["Living room"] + TV["📺 Fire TV · Vega OS
React Native for Vega"] + P["📱 Phones
(browser, no install)"] end - subgraph TVa["apps/tv/audio"] - AP["AudioProvider
place, events, settings"] - BE["w3cBackend
players, pooling, lifecycle counters"] + subgraph Server["Game server · Node.js + TypeScript"] + WS["WebSocket gateway"] + GS["Rooms + GameSession"] + ENG["Game engine
(pure, deterministic)"] + MD["Mystery data
+ truth tables"] + AI["Guarded AI + voices"] end - GSt["GameState + world place"] --> AP - MAN & DIRN --> AD - AP --> AD --> BE --> SPK["W3C media player"] - SET["Settings: master, music, effects, voice, subtitles"] --> AP + TV <-->|"intents / state"| WS + P <-->|"intents / state"| WS + WS --> GS --> ENG --> MD + GS --> AI + AI -.-> LLM["Groq · Edge TTS
(free tiers)"] ``` -- No component names a file: everything is an id resolved through the manifest. -- Effective volume = master × channel × sound volume (× duck for music under voice). -- A missing file never blocks play: it is logged once (`[AUDIO_MISSING]` with id, reason and scene), voice lines keep their subtitles, music and ambience skip. -- A development trace (`[AUDIO_TRACE]`) logs every command and its cause; `apps/tv/scripts/trace-sink.js` collects it from the device. -- Jest journey tests drive the real provider, director and backend through a whole case and assert order, ducking, cleanup and player counts. - ---- - -## 10. AI layer +**Ground rules** +- **Data flows one way:** mystery data → game engine → game state → UI. +- **The engine is mystery-agnostic and does no I/O.** Time is passed in, so any game can be replayed from its action log. +- **Presentation is not logic.** The 2.5D world, audio and AI react to the game state; they never decide anything. +- **The protocol lives in one place** (`packages/shared-types`) and is shared by the TV, the phones and the server. -**Status: interrogation is live on the server** (`apps/server/src/ai/`); the phone/TV interface, hints, narration and voices come next. Keys live only in `apps/server/.env` (git-ignored). The game remains fully playable without AI. +### Repository layout -### 10.1 What the AI does -1. **Interrogation:** players pick a suspect on their phone and ask anything (typed, or spoken and transcribed). The suspect answers in character on the TV, with voice and subtitles. -2. **Adaptive hints:** the AI rephrases the next authored hint level for the group's situation. It cannot go beyond that level. -3. **Narration:** the narrator (detective, curator or Yaksha) reacts to discoveries and theories. - -### 10.2 What the AI can never do -- Decide whether an answer is correct, unlock anything, or change score or state. Only the engine does. -- See a fact the room has not unlocked. Locked facts are never in the prompt. -- Say a guarded term early. Every reply is checked with `spoilersIn` before anyone sees it. -- Invent evidence. Replies are limited to speakable facts and clues already found. - -### 10.3 Interrogation flow - -```mermaid -sequenceDiagram - autonumber - participant P as Phone - participant S as Server (GameSession) - participant G as Truth gates (engine) - participant A as AI gateway - participant M as Gemini / Groq - participant T as TV - P->>S: ASK_CHARACTER {characterId, question, evidenceClueId?} - S->>S: per-room budget, per-player cooldown, length cap - S->>T: CHARACTER_THINKING - S->>G: speakableFacts(character, state), activeGuardedTerms(state) - G-->>S: allowed facts, banned terms - S->>A: persona + allowed facts + found clues + question - A->>M: generate (short, in character) - alt provider error or limit - A->>M: next provider - end - M-->>A: reply - A->>G: spoilersIn(reply) + length + off-limits check - alt unsafe, or every provider failed - A-->>S: authored fallback line - else safe - A-->>S: reply - end - S->>T: CHARACTER_REPLY {characterId, text, audio} - S->>P: CHARACTER_REPLY ``` - -### 10.4 Cost and reliability -- Free tiers only, in `AI_ORDER` (default Groq `openai/gpt-oss-120b`, measured 0.5–1.2 s per line, then Gemini), then authored lines. Players never pay and never see a key. -- A per-room question budget (30 per playthrough), a per-player cooldown (4 s), short outputs, a 6 s timeout per provider and reply caching keep usage inside free limits. A rate-limited provider rests for a minute; a denied key rests for ten. -- `npx tsx apps/server/scripts/voice-samples.ts ` writes one sample per character to audition the casting. -- `npm run ai-smoke -w @roomriddle/server [groq|gemini]` asks real suspects with the real keys and prints which source answered. - -### 10.5 Code map -| File | Role | -| --- | --- | -| `ai/prompt.ts` | Builds the prompt from persona, unlocked facts (minus any holding a live spoiler) and found clue titles. Player text is fenced. | -| `ai/providers.ts` | Gemini and Groq REST clients (key in headers, never in URLs). | -| `ai/guard.ts` | Strips stage directions, caps at three sentences, rejects spoilers, unsolved typed answers and out-of-character lines. | -| `ai/fallback.ts` | Authored replies from unlocked first-person facts, or a deflection in a tone suited to the case. | -| `ai/voice.ts` | Spoken replies: Edge neural voices (free, no key) via `msedge-tts`, content-addressed in-memory cache, `CHARACTER_VOICE` after the text so subtitles never wait. Each character's voice is data (`CharacterTruth.voice`). | -| `ai/CharacterService.ts` | Budget, cooldown, cache, provider chain, history (last three exchanges per character). Never changes game state. | - -### 10.6 Server settings -| Variable | Default | Meaning | -| --- | --- | --- | -| `GROQ_API_KEY`, `GEMINI_API_KEY` | none | Free-tier keys, in `apps/server/.env` only. | -| `AI_ORDER` | `groq,gemini` | Provider order. | -| `GROQ_MODEL` / `GEMINI_MODEL` | `openai/gpt-oss-120b` / `gemini-3.8-flash` | Models. | -| `AI_DISABLED` | unset | `1` = authored lines only (offline demo). | -| `VOICES_DISABLED` | unset | `1` = subtitles only. | -| `AI_TIMEOUT_MS`, `AI_MAX_QUESTIONS`, `AI_COOLDOWN_MS` | 6000, 30, 4000 | Limits. | -- Voice: server-side free TTS, one fixed voice per character, subtitles always shown. Speech input via Groq Whisper from the phone microphone. - ---- - -## 11. Expandable mystery library - -**Status: planned.** Downloaded code cannot safely be executed on the TV, so a mystery pack is **data only**: it describes a case using puzzle types, interactions and effects the installed engine already supports. - -```mermaid -flowchart LR - subgraph Authoring - AU["Author writes case
(TypeScript data)"] --> VAL["validateMysteryDefinition
+ asset check + playthrough test"] - VAL --> PK["Pack: manifest.json + assets
id, version, engineVersion, checksums"] - end - PK --> CDN["Object storage / CDN"] - PK --> CAT["Catalogue API + DB
(cases, versions, entitlements)"] - subgraph Device - LIB["Library screen"] --> CAT - LIB --> DL["Downloader
resume, checksum, engine compatibility"] - DL --> CACHE["Local pack cache"] - CACHE --> RUN["Engine + world + audio
(same runtime as built-in cases)"] - end - CDN --> DL - STORE["Amazon Appstore IAP"] --> CAT +apps/ + tv/ Fire TV app: React Native for Vega (RN 0.83), TypeScript + player/ Phone app: plain TypeScript + DOM, bundled with esbuild, served by the server + server/ Authoritative game server: HTTP + WebSocket, rooms, AI and voice gateway +packages/ + shared-types/ Protocol, game state, mystery schema, world and audio types + game-engine/ Pure rules: applyAction(mystery, state, action) → { state, events } + mystery-data/ The five cases: scenes, clues, puzzles, hints, endings, truth tables + scene/ Camera, director, layout, asset registry, audio director + client/ Protocol client shared by the TV and phones (reconnects, message reassembly) + ui/ Design tokens: colour, type, spacing, motion +docs/ System design, development, testing, game design, friction log, research ``` -- The server loads the same pack version as the TV before a room starts, so it stays authoritative. -- Packs declare a minimum engine version; incompatible packs are hidden, not crashed. -- Purchases are entitlements verified with the store, separate from downloaded files. +The full technical reference is in **[docs/system-design.md](docs/system-design.md)**: the engine, the truth-table model, the multiplayer protocol, the 2.5D renderer, audio and the AI layer. ---- +### Built with -## 12. Security and privacy - -- Answer keys, hint text, clue descriptions and truth tables never reach a client before they are earned (`PublicMystery` + `GameReveals`). -- Every client frame is shape-validated; unknown fields are dropped; oversize frames are refused; per-connection rate limits apply. -- Session tokens are random 32 bytes, compared in constant time. -- No accounts and no personal data: a player is a nickname for the life of a room. Rooms live in memory. -- API keys are server-side only (`.env`, git-ignored). Questions go to the AI provider without names or identifiers. +| Layer | Technology | +|---|---| +| TV | React Native for Vega 0.83 · Vega SDK · `@amazon-devices/react-native-w3cmedia` (audio + Media Source) · Async Storage | +| Phone | TypeScript · DOM · esbuild · QR join | +| Server | Node.js · TypeScript · `ws` · ffmpeg (voice clips to fragmented MP4) | +| AI | Groq `openai/gpt-oss-120b` · Groq Orpheus TTS · Microsoft Edge neural TTS (`msedge-tts`) | +| Quality | `node:test` + `tsx` · Jest + React Native Testing Library · ESLint · GitHub Actions | --- -## 13. Testing and quality +## ✦ Quality -| Suite | Runner | Covers | -|---|---|---| -| `packages/*`, `apps/server`, `apps/player` | `node:test` via `tsx` (`npm run check`) | Engine rules, scoring, validation, playable cases end to end, content validation, scene/camera/director/audio director, protocol, rooms, reconnection | -| `apps/tv` | Jest + React Native Testing Library (`wsl-check.sh`) | Navigation, screens, world, settings, full audio journey through the real game (69 tests) | -| Device | Vega Virtual Device scripts | `vvd-shot.sh` (remote keys + screenshots), `vvd-load.sh` (CPU), audio capture to WAV (`vvd-wav-start.sh`), audio trace sink | -| Lint / types | `tsc --noEmit`, ESLint | All workspaces | - -About 270 automated tests run before every commit. See `docs/testing.md`. +- **About 320 automated tests:** 58 engine, 22 content, 52 scene, 19 client, 4 UI tokens, 78 server, 11 phone and 75 TV. +- **Every case is played to the end automatically.** Content validation fails the build on unreachable scenes, broken references, puzzles that need clues from later scenes, and spoilers that could never unlock. +- **Spoiler-attack tests** run against the AI guard and the prompt builder. +- **Scripted device QA:** the Vega Virtual Device is driven with remote key presses and screenshots, with an audio trace sink. +- CI runs the root checks on every push and pull request. --- -## 14. Setup and running - -### Requirements -- Node.js ≥ 22, npm ≥ 10. -- TV app: Vega SDK on Ubuntu 20.04+ or macOS 10.15+ (this project also runs it under WSL2 on Windows 11; see `docs/development.md`). +## ✦ Status -### Server + phones (Windows, macOS or Linux) -```bash -npm install -npm run check # typecheck + all package tests -npm run serve # build the phone app, start the server on 0.0.0.0:4000 -``` -Optional AI configuration in `apps/server/.env` (never commit it): -``` -GEMINI_API_KEY=... -GROQ_API_KEY=... -``` - -### TV app -```bash -# inside Ubuntu (or WSL2) with the Vega SDK -source ~/vega/env -bash apps/tv/scripts/wsl-check.sh # tsc + eslint + jest -bash apps/tv/scripts/wsl-build.sh # build the .vpkg in an ext4 mirror -bash apps/tv/scripts/wsl-install.sh # install and launch on the VVD -``` -- On the VVD the app reaches the server at `ws://10.0.2.2:4000/ws` through `apps/tv/scripts/vvd-bridge.js`. -- On a physical Fire TV, set `apps/tv/src/config/server.ts` to `ws://:4000/ws`. - -### Play -1. TV: **Create Room**. -2. Phones on the same Wi-Fi: scan the QR code. -3. TV: **Start**. - -Without a TV: `npm run fake-tv -w @roomriddle/server`, then `npm run play-bots -w @roomriddle/server -- ` plays a scripted game. +| Area | State | +|---|---| +| Game engine, five playable cases, truth tables | ✅ Done, tested | +| Multiplayer server: rooms, QR join, reconnection, host pause | ✅ Done, tested | +| Phone app: Scene, Evidence and Question tabs | ✅ Done, tested | +| TV app: screens, lobby, settings, library, 2.5D world, audio | ✅ Done, verified on the Vega Virtual Device | +| AI suspect questioning with voices on the TV | ✅ Done, verified on the Vega Virtual Device | +| Painted art, video loops and final audio | 🎨 In progress (assets in production) | +| UI redesign to the final art direction | 🎨 In progress | +| AI hints and narrator | 🔜 Next | +| Validation on Fire TV Stick hardware | 🔜 Next | +| Playable web demo | 🔜 Planned | + +**Known limitations:** server state is in memory, so a restart ends all rooms. Audio on the Vega Virtual Device crackles on our development machine; this comes from the emulator, not the app (see [FL-013](docs/friction-log.md)). --- -## 15. Performance - -Measured on the Vega Virtual Device (4 vCPU, software rendering), so these are upper bounds: +## ✦ Documentation -| Situation | System CPU | +| Document | What's inside | |---|---| -| Idle VVD | ~13% | -| Static screen, reduced motion | ~5% | -| World exploration | 56–62% | -| Arrival cinematic | ~75% | -| Memory | ~600 MB of 3.9 GB | - -Every animation uses the native driver and the frame loop runs only while something moves. Real hardware composites on the GPU and will be measured on a Fire TV Stick. +| [docs/system-design.md](docs/system-design.md) | Architecture, engine, content model, protocol, TV renderer, audio, AI, security | +| [docs/development.md](docs/development.md) | Environment setup, Vega SDK under WSL2, the daily loop | +| [docs/testing.md](docs/testing.md) | Test strategy, suites and commands | +| [docs/game-design.md](docs/game-design.md) | Game design notes | +| [docs/architecture.md](docs/architecture.md) | Design decisions, phase by phase | +| [docs/friction-log.md](docs/friction-log.md) | Verified problems we hit with Vega, the emulator and tooling, with workarounds | +| [docs/research/market-research.md](docs/research/market-research.md) | Audience, family impact, market and validation plan | +| [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute, conventions, and how to write a new case | +| [CHANGELOG.md](CHANGELOG.md) | What changed, milestone by milestone | +| [CREDITS.md](CREDITS.md) | Art, voices, fonts and third-party services | --- -## 16. Known limitations +## ✦ License -- Emulator audio on the development PC crackles and lags; this is the VVD/WSLg pipeline, not the app (FL-013). Clean audio must be confirmed on hardware. -- Art and sounds are placeholders until the asset pass lands. -- Server state is in memory; a restart ends all rooms. -- The AI layer and cases 2–5 are in progress. +[MIT](LICENSE) © 2026 Chitraksh Kumar. ---- - -## 17. Documentation index - -| Document | Contents | -|---|---| -| `docs/architecture.md` | Detailed design decisions per phase | -| `docs/development.md` | Environment setup, Vega under WSL2, daily loop | -| `docs/testing.md` | Test strategy and commands | -| `docs/game-design.md` | Game design notes | -| `docs/friction-log.md` | Verified problems with Vega, the VVD and tooling (product feedback) | -| `docs/research/market-research.md` | Market, audience, family impact, monetization, validation plan | -| `AGENTS.md` | Rules for every contributor | - -## License -[MIT](LICENSE) +Fire TV, Vega OS and Amazon are trademarks of Amazon.com, Inc. or its affiliates. Room Riddle is an independent hackathon project and is not endorsed by Amazon. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3f1c4d2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +## Reporting a vulnerability +Please **do not open a public issue**. Use GitHub's private reporting: [Report a vulnerability](https://github.com/perhaps-aksh/RoomRiddle/security/advisories/new). Expect a reply within a few days. + +## What matters most here +- Anything that lets a client read answer keys, hints, clue text or truth-table facts before the room has earned them. +- A client changing game state without a valid intent (forged results, another player's seat, session hijacking). +- Prompt injection that makes an AI suspect reveal a guarded spoiler or break character. +- Leaks of server-side API keys. + +## Design notes +- Keys live only in `apps/server/.env` (git-ignored) and never reach a client. +- Every client frame is shape-validated and rate-limited; session tokens are 32 random bytes compared in constant time. +- There are no accounts and no stored personal data: a player is a nickname for the life of a room, and rooms live in memory. + +See [docs/system-design.md](docs/system-design.md#security-and-privacy). diff --git a/apps/player/public/icon-192.png b/apps/player/public/icon-192.png new file mode 100644 index 0000000..20154c6 Binary files /dev/null and b/apps/player/public/icon-192.png differ diff --git a/apps/player/public/icon-512.png b/apps/player/public/icon-512.png new file mode 100644 index 0000000..5d2ac76 Binary files /dev/null and b/apps/player/public/icon-512.png differ diff --git a/apps/player/public/index.html b/apps/player/public/index.html index e98de8a..f023142 100644 --- a/apps/player/public/index.html +++ b/apps/player/public/index.html @@ -5,13 +5,17 @@ - RoomRiddle · Join the investigation + Room Riddle · Join the investigation + + + +

Opening case file…

- + diff --git a/apps/player/public/manifest.webmanifest b/apps/player/public/manifest.webmanifest new file mode 100644 index 0000000..d9e5e25 --- /dev/null +++ b/apps/player/public/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Room Riddle", + "short_name": "Room Riddle", + "description": "Gather. Investigate. Unravel the Mystery.", + "start_url": "/", + "display": "standalone", + "background_color": "#07090E", + "theme_color": "#07090E", + "icons": [ + {"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"}, + {"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"} + ] +} diff --git a/apps/player/src/render.ts b/apps/player/src/render.ts index 4e0fbc9..0bda70b 100644 --- a/apps/player/src/render.ts +++ b/apps/player/src/render.ts @@ -97,7 +97,7 @@ function topBar(view: RoomView): string { : null; const room = view.room ?? (view.session ? {code: view.session.roomCode} : null); return `
- RoomRiddle + Room Riddle ${room ? `RM ${esc(room.code)}` : ''} ${status ? `${status.text}` : ''} @@ -187,7 +187,7 @@ function lobbyScreen(view: RoomView): string { return `
Joined

Case file

-

${esc(view.mystery?.summary.title ?? 'RoomRiddle')}

+

${esc(view.mystery?.summary.title ?? 'Room Riddle')}

Investigator #${String(seat).padStart(2, '0')} ${esc(self?.name.toUpperCase() ?? '')} diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 0000000..1cf0f6a --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,37 @@ +# Room Riddle server settings. Copy to apps/server/.env (git-ignored) and fill in. +# Every value is optional: with nothing set, the game runs fully offline with +# authored suspect lines and subtitles only. Real environment variables win. + +# --- Network --------------------------------------------------------------- +# PORT=4000 +# HOST=0.0.0.0 +# Base URL phones use for join links and the QR code (auto-detected LAN IP if unset). +# PUBLIC_URL=http://192.168.1.20:4000 + +# --- AI suspects (free tiers) ----------------------------------------------- +# Free key: https://console.groq.com/keys +GROQ_API_KEY= +# GROQ_MODEL=openai/gpt-oss-120b +# Optional second provider (Google AI Studio key). +GEMINI_API_KEY= +# GEMINI_MODEL=gemini-3.8-flash +# AI_ORDER=groq,gemini +# 1 = authored lines only (offline demo). +# AI_DISABLED=1 +# AI_TIMEOUT_MS=6000 +# AI_MAX_QUESTIONS=30 +# AI_COOLDOWN_MS=4000 + +# --- Voices ------------------------------------------------------------------- +# Edge neural voices need no key; Groq Orpheus uses GROQ_API_KEY (accept the model +# terms once in the Groq console). The TV needs ffmpeg on the server to stream clips. +# VOICE_ORDER=edge,groq +# VOICES_DISABLED=1 +# FFMPEG_PATH=/usr/bin/ffmpeg + +# --- Rooms -------------------------------------------------------------------- +# ROOM_MIN_PLAYERS=2 +# ROOM_MAX_PLAYERS=6 +# MAX_ROOMS=200 +# LOBBY_PLAYER_GRACE_MS=120000 +# HOST_GRACE_MS=600000 diff --git a/apps/tv/assets/image/roomriddle_icon.png b/apps/tv/assets/image/roomriddle_icon.png new file mode 100644 index 0000000..5d2ac76 Binary files /dev/null and b/apps/tv/assets/image/roomriddle_icon.png differ diff --git a/apps/tv/manifest.toml b/apps/tv/manifest.toml index 2ae80ab..3c5d285 100644 --- a/apps/tv/manifest.toml +++ b/apps/tv/manifest.toml @@ -1,7 +1,8 @@ schema-version = 1 [package] -title = "Basic UI React Native Application for project roomriddletv" +title = "Room Riddle" +icon = "@image/roomriddle_icon.png" version = "0.1.0" id = "com.roomriddle.tv" diff --git a/apps/tv/src/screens/Home/HomeScreen.tsx b/apps/tv/src/screens/Home/HomeScreen.tsx index b546060..3f34dd5 100644 --- a/apps/tv/src/screens/Home/HomeScreen.tsx +++ b/apps/tv/src/screens/Home/HomeScreen.tsx @@ -68,7 +68,7 @@ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: Scre - ROOMRIDDLE + ROOM RIDDLE @@ -79,7 +79,7 @@ export const HomeScreen = ({navigator, initialFocusKey, selectedMysteryId}: Scre {confirmExit ? ( CONFIRM - Leave RoomRiddle? + Leave Room Riddle? setConfirmExit(false)} preferredFocus testID="home-exit-stay" /> BackHandler.exitApp()} testID="home-exit-confirm" /> diff --git a/apps/tv/test/App.spec.tsx b/apps/tv/test/App.spec.tsx index d0576fa..129697f 100644 --- a/apps/tv/test/App.spec.tsx +++ b/apps/tv/test/App.spec.tsx @@ -17,10 +17,10 @@ afterEach(() => { jest.useRealTimers(); }); -describe('RoomRiddle TV shell', () => { +describe('Room Riddle TV shell', () => { it('starts on Home with the four menu items', () => { const screen = render(); - expect(screen.getByText('ROOMRIDDLE')).toBeTruthy(); + expect(screen.getByText('ROOM RIDDLE')).toBeTruthy(); expect(screen.getByText('Gather. Investigate. Unravel.')).toBeTruthy(); for (const id of ['createRoom', 'joinRoom', 'mysteries', 'settings']) { expect(screen.getByTestId(`home-${id}`)).toBeTruthy(); @@ -169,7 +169,7 @@ describe('remote Back button', () => { mockBackHandler.exitApp.mockClear(); const screen = render(); expect(back()).toBe(true); - expect(screen.getByText('Leave RoomRiddle?')).toBeTruthy(); + expect(screen.getByText('Leave Room Riddle?')).toBeTruthy(); expect(mockBackHandler.exitApp).not.toHaveBeenCalled(); fireEvent.press(screen.getByTestId('home-exit-stay')); expect(screen.getByTestId('home-createRoom')).toBeTruthy(); diff --git a/docs/images/banner.jpg b/docs/images/banner.jpg new file mode 100644 index 0000000..3873334 Binary files /dev/null and b/docs/images/banner.jpg differ diff --git a/docs/images/hero-tv-phone.jpg b/docs/images/hero-tv-phone.jpg new file mode 100644 index 0000000..2082a52 Binary files /dev/null and b/docs/images/hero-tv-phone.jpg differ diff --git a/docs/images/icon.png b/docs/images/icon.png new file mode 100644 index 0000000..c9136d9 Binary files /dev/null and b/docs/images/icon.png differ diff --git a/docs/images/phones.jpg b/docs/images/phones.jpg new file mode 100644 index 0000000..24a415c Binary files /dev/null and b/docs/images/phones.jpg differ diff --git a/docs/images/tv-home.jpg b/docs/images/tv-home.jpg new file mode 100644 index 0000000..3dd277b Binary files /dev/null and b/docs/images/tv-home.jpg differ diff --git a/docs/images/tv-intro.jpg b/docs/images/tv-intro.jpg new file mode 100644 index 0000000..e5fb0fd Binary files /dev/null and b/docs/images/tv-intro.jpg differ diff --git a/docs/images/tv-library.jpg b/docs/images/tv-library.jpg new file mode 100644 index 0000000..4e7541a Binary files /dev/null and b/docs/images/tv-library.jpg differ diff --git a/docs/images/tv-lobby.jpg b/docs/images/tv-lobby.jpg new file mode 100644 index 0000000..e3d1361 Binary files /dev/null and b/docs/images/tv-lobby.jpg differ diff --git a/docs/images/tv-station.jpg b/docs/images/tv-station.jpg new file mode 100644 index 0000000..451f31b Binary files /dev/null and b/docs/images/tv-station.jpg differ diff --git a/docs/images/tv-suspect.jpg b/docs/images/tv-suspect.jpg new file mode 100644 index 0000000..67e1bd7 Binary files /dev/null and b/docs/images/tv-suspect.jpg differ diff --git a/docs/images/tv-world.jpg b/docs/images/tv-world.jpg new file mode 100644 index 0000000..0677cde Binary files /dev/null and b/docs/images/tv-world.jpg differ diff --git a/docs/system-design.md b/docs/system-design.md new file mode 100644 index 0000000..5251993 --- /dev/null +++ b/docs/system-design.md @@ -0,0 +1,511 @@ +# Room Riddle: system design + +The technical reference: how the pieces fit, the rules they follow, and why. For setup see [development.md](development.md); for per-phase decisions see [architecture.md](architecture.md). + +## Contents +- [System architecture](#system-architecture) +- [Repository layout](#repository-layout) +- [The game engine](#the-game-engine) +- [Mystery content model and truth tables](#mystery-content-model-and-truth-tables) +- [Multiplayer](#multiplayer) +- [TV app on Vega OS](#tv-app-on-vega-os) +- [Audio system](#audio-system) +- [AI layer](#ai-layer) +- [Expandable mystery library](#expandable-mystery-library) +- [Security and privacy](#security-and-privacy) +- [Testing and quality](#testing-and-quality) +- [Performance](#performance) + +## System architecture + +### Context + +```mermaid +flowchart LR + subgraph Home["Living room"] + TV["Fire TV (Vega OS)
RoomRiddle TV app
React Native for Vega"] + P1["Phone 1
browser"] + P2["Phone 2
browser"] + P3["Phone N
browser"] + end + subgraph Server["RoomRiddle server (Node.js + TypeScript)"] + WS["WebSocket gateway
/ws"] + HTTP["HTTP
/health, phone app, /join/:code"] + RM["Rooms + sessions"] + GS["GameSession"] + ENG["Game engine
(pure, deterministic)"] + MD["Mystery data
+ truth tables"] + AI["AI gateway
(guarded)"] + end + LLM1["Groq API
gpt-oss-120b + Orpheus TTS
(free tier)"] + LLM2["Edge neural TTS
Gemini (optional)"] + + TV <-->|"intents / snapshots"| WS + P1 <--> WS + P2 <--> WS + P3 <--> WS + P1 -->|"scan QR"| HTTP + WS --> RM --> GS --> ENG --> MD + GS --> AI + AI -.-> LLM1 + AI -.-> LLM2 +``` + +Dashed edges are optional cloud calls. With no keys the game still plays: suspects fall back to authored lines. + +### Layers + +```mermaid +flowchart TB + subgraph Presentation + TVUI["apps/tv
screens, 2.5D world, audio renderer"] + PH["apps/player
phone DOM app"] + end + subgraph Contracts + ST["packages/shared-types
protocol, game, mystery, world, audio"] + CL["packages/client
RoomClient, reducer, copy"] + UI["packages/ui
design tokens"] + end + subgraph Logic + EN["packages/game-engine
reducer, validation, scoring, truth gates"] + SC["packages/scene
camera, director, layout, AudioDirector"] + end + subgraph Content + MD["packages/mystery-data
cases, worlds, audio direction, truth tables"] + end + SV["apps/server
authority"] + + TVUI --> CL & UI & SC & ST + PH --> CL & UI & ST + SV --> EN & MD & ST + EN --> ST + SC --> ST + MD --> ST +``` + +Rules: +- **Data flows one way:** `Mystery Data → Game Engine → GameState → UI`. +- **Clients send intents, never results.** A phone sends "my answer is 1147", never "I solved it". +- **The engine is mystery-agnostic.** No case id appears in engine or UI code; everything specific is data. +- **The engine does no I/O and never reads a clock.** Time is passed in, so any game replays from its action log. +- **Presentation is not logic.** The 2.5D world and the audio system react to `GameState`; they never decide anything. + +--- + +## Repository layout + +``` +apps/ + tv/ Vega OS TV app (React Native for Vega 0.83, TypeScript). Not an npm workspace (Vega SDK is Linux/macOS). + player/ Phone companion: plain TypeScript + DOM, bundled by esbuild, served by the server. + server/ Authoritative game server: HTTP + WebSocket (ws), rooms, sessions, QR, AI gateway. +packages/ + shared-types/ Single source of truth: protocol, game state, mystery schema, world and audio types. + game-engine/ Pure deterministic rules: applyAction(mystery, state, action) → {state, events}. + mystery-data/ Authored cases: scenes, clues, puzzles, hints, endings, worlds, audio direction, truth tables. + scene/ Framework-free presentation logic: camera, director, layout, assets, AudioDirector. + client/ Framework-free protocol client shared by TV and phone. + ui/ Design tokens (colour, type, spacing, motion) for TV and phone. +docs/ Architecture, development, game design, testing, friction log, research. +``` + +--- + +## The game engine + +`packages/game-engine` is a pure reducer. + +```mermaid +flowchart LR + A["GameAction
(intent + time)"] --> R{"applyAction"} + S0["GameState n"] --> R + M["MysteryDefinition"] --> R + R --> S1["GameState n+1"] + R --> E["GameEvents
(feedback only)"] + R -->|"invalid"| X["same state +
'rejected' event"] +``` + +- **Actions:** `START_MYSTERY`, `ADVANCE_SCENE`, `DISCOVER_CLUE`, `OPEN_PUZZLE`, `CLOSE_PUZZLE`, `SUBMIT_ANSWER`, `REQUEST_HINT`, `MAKE_CHOICE`, `PAUSE`, `RESUME`, `TICK`, `COMPLETE_MYSTERY`. +- **Invalid actions never throw.** They return the unchanged state plus a `rejected` event, so a stale or duplicated phone message is harmless. +- **Answer validation is exact.** Input kinds: `digits` (`11:47` = `11 47` = `1147`), `choice`, `combination` (one option per field, also used for ordering and matching) and `text` (case, spacing and punctuation folded, then exact match). No fuzzy matching. +- **Scoring, hints, timers and endings are data.** Hints are three authored levels (nudge, connect, explain). Endings are ordered condition lists; the first match wins. +- **State is plain JSON** with a schema version: saved, broadcast, and rebuilt with `replay(mystery, actions)`. + +```mermaid +stateDiagram-v2 + [*] --> Lobby + Lobby --> Intro: START_GAME (host) + Intro --> Scene: ADVANCE_SCENE + Scene --> Scene: DISCOVER_CLUE + Scene --> Puzzle: requirements met + Puzzle --> Puzzle: wrong answer / hint + Puzzle --> Scene: solved (next unlocks) + Scene --> NextScene: ADVANCE_SCENE (scene complete) + NextScene --> Scene + Puzzle --> Ending: final puzzle solved + Scene --> Ending: timer expired / too many attempts + Ending --> Lobby: replay +``` + +--- + +## Mystery content model and truth tables + +Every case is a `MysteryDefinition` (TypeScript data in `packages/mystery-data//`): + +```mermaid +classDiagram + class MysteryDefinition { + summary + audience + intro + startSceneId + scenes[] + clues[] + puzzles[] + choices[] + endings[] + scoring + failure + truth (server-only) + } + class Scene { id; narration; clueIds; puzzleIds; nextSceneId } + class Clue { id; sceneId; prompt; description; major; requires } + class Puzzle { id; sceneId; input; acceptedAnswers; hints[3]; requires; solvedText; completesMystery } + class Ending { id; tone; when[]; narration } + class MysteryTruth { narratorId; solution; characters[]; guarded[]; offLimits } + class CharacterTruth { id; name; role; persona; interviewable; facts[] } + class TruthFact { id; kind; false; text; unlockedBy } + class GuardedTerm { terms[]; until } + MysteryDefinition --> Scene + MysteryDefinition --> Clue + MysteryDefinition --> Puzzle + MysteryDefinition --> Ending + MysteryDefinition --> MysteryTruth + MysteryTruth --> CharacterTruth + CharacterTruth --> TruthFact + MysteryTruth --> GuardedTerm +``` + +**Truth tables** are the authored ground truth for each character: +- a **claim** is what they say publicly (and may be flagged false); +- a **secret** is true but only becomes speakable once its `unlockedBy` requirement (clues found, puzzles solved) holds; +- **guarded terms** are spoilers ("11:47", "lever 7") that no voice may say until the room has earned them. + +The engine exposes pure gates over them: `speakableFacts`, `lockedFacts`, `activeGuardedTerms`, `spoilersIn`. The truth table is stripped from everything sent to clients (`toPublicMystery`), and game rules never read it. + +`validateMysteryDefinition` runs in tests for every playable case. It fails the build on unreachable scenes, unknown ids, malformed answers, puzzles that need clues from later scenes, broken truth-table references, secrets that are never locked, and guarded terms that can never unlock. + +--- + +## Multiplayer + +### Server components + +```mermaid +flowchart LR + TV["TV (host)"] -- ws --> GW + PH1["Phone"] -- ws --> GW + PH2["Phone"] -- ws --> GW + GW["websocket/server
messages.ts (validate), router"] --> RMG["RoomManager"] + GW --> SM["SessionManager
(tokens)"] + GW --> CM["ConnectionManager
(sockets ↔ seats, broadcast)"] + RMG --> ROOM["Room"] + ROOM --> PM["PlayerManager"] + ROOM --> GSN["GameSession"] + GSN --> ENG["game-engine.applyAction"] +``` + +### Protocol + +`packages/shared-types/src/protocol.ts` is the single source of truth. + +| Direction | Messages | +|---|---| +| Client → server | `CREATE_ROOM`, `JOIN_ROOM`, `RECONNECT`, `LEAVE_ROOM`, `READY`, `START_GAME`, `DISCOVER_CLUE`, `OPEN_PUZZLE`, `CLOSE_PUZZLE`, `SUBMIT_ANSWER`, `REQUEST_HINT`, `ADVANCE_SCENE`, `MAKE_CHOICE` | +| Server → client | `ROOM_CREATED`, `ROOM_JOINED`, `RECONNECT_SUCCESS`, `ROOM_STATE`, `PLAYER_JOINED/UPDATED/LEFT`, `GAME_STARTED`, `GAME_STATE_UPDATED`, `GAME_COMPLETED`, `LEFT_ROOM`, `ROOM_CLOSED`, `ERROR` | + +Every server message carries `serverTime`; replies echo the request's `requestId`. + +### A turn, end to end + +```mermaid +sequenceDiagram + autonumber + participant P as Phone (Aksh) + participant S as Server + participant E as Game engine + participant T as TV + participant Q as Other phones + P->>S: SUBMIT_ANSWER {station-clock, "11:47"} + S->>S: validate frame, rate limit, seat + S->>E: applyAction(mystery, state, {SUBMIT_ANSWER, at: now}) + E-->>S: state', events [answer-correct, puzzle-unlocked] + S->>T: GAME_STATE_UPDATED {gameState, update: {actorName, events}, reveals} + S->>Q: GAME_STATE_UPDATED + S->>P: GAME_STATE_UPDATED + T->>T: world reacts: detective line, discovery sound, camera move +``` + +### Joining and reconnection + +```mermaid +sequenceDiagram + participant T as TV + participant S as Server + participant P as Phone + T->>S: CREATE_ROOM {mysteryId} + S-->>T: ROOM_CREATED {code, QR matrix, session} + P->>S: GET /join/:code (scan QR) + S-->>P: phone app + P->>S: JOIN_ROOM {code, "Aksh"} + S-->>P: ROOM_JOINED {room, session token, PublicMystery} + S-->>T: PLAYER_JOINED + Note over P,S: Wi-Fi drops + P--xS: socket closed (seat kept, connected=false) + P->>S: RECONNECT {playerId, sessionToken} + S-->>P: RECONNECT_SUCCESS (full current state, same seat) +``` + +- Room codes: 4 random digits (`crypto.randomInt`), unique among live rooms. +- Session tokens: random 32 bytes, compared in constant time; a newer connection for a seat closes the older one. +- If the **host TV** drops, the game pauses (the clock stops) and phones show "waiting for the TV". A room whose host is gone for 10 minutes closes. +- Limits: 2–6 phone players, 200 rooms, per-connection rate limit, 10 s heartbeats. State is in memory by design. +- Vega's WebSocket client can split one message across several `onmessage` events (FL-011); `RoomClient` reassembles them. + +--- + +## TV app on Vega OS + +### Platform features used + +| Vega / Fire TV capability | How RoomRiddle uses it | +|---|---| +| React Native for Vega (RN 0.83) | The whole TV app | +| D-pad focus management (preferred focus, explicit neighbours, focus guides) | Every screen is remote-driven, with a focus frame readable from the couch | +| Back key handling | Back never destroys a room or progress without confirmation | +| W3C media (`@amazon-devices/react-native-w3cmedia`) | Music, ambience, effects and voice through one data-driven backend | +| App lifecycle (foreground/background) | Audio mutes in the background and resumes on return | +| Async storage | Settings persist across launches | +| System fonts (Bookerly, Amazon Ember, Ember Mono) | Three type voices, no bundled fonts | +| Vega Virtual Device + CLI | Scripted device QA (remote keys + screenshots) | +| Planned: Content Launcher / deep links | Open RoomRiddle straight into a case | + +### Screens and modes + +```mermaid +flowchart LR + Home --> Mysteries["Mystery library"] --> Details --> Intro + Home --> Create["Create room
(code + QR + lobby)"] --> Intro + Home --> Settings --> Diag["Audio diagnostics (dev)"] + Intro --> World["2.5D world
(cinematic setting)"] + Intro --> CaseFile["Case-file scenes
(casefile setting)"] + World --> Inspect["Inspect object"] --> World + World --> Evidence & Puzzle + CaseFile --> Evidence & Puzzle + Puzzle --> World + World --> Ending + CaseFile --> Ending +``` + +### The 2.5D cinematic world + +2D art in depth planes with a camera, not a 3D game, so it stays smooth on a streaming stick. + +```mermaid +flowchart TB + WD["mystery-data/<case>/world
manifest, sets, shots, cinematics, dialogue"] --> DIR + subgraph scene["packages/scene (pure, tested)"] + DIR["Director
modes, timeline, dialogue, characters, fades"] + CAM["Camera
shots, moves, shake, lookShot, occluders"] + LAY["Layout
objects on planes"] + AST["Asset registry
final → fallback → placeholder"] + INT["Interactions
object → engine GameAction"] + end + DIR --> STAGE + CAM --> STAGE + LAY --> STAGE + AST --> STAGE + STAGE["apps/tv/world
Stage, AssetView, Actors, Effects (rain, fog, steam, lightning)"] + INT -->|"DISCOVER_CLUE"| GP["GameProvider → server"] + GP -->|"GameState"| DIFF["diffEvents → reactions
(dialogue, sound, camera)"] + DIFF --> DIR +``` + +- **Parallax is one rule:** translate = −camera × depth. +- **Assets are swappable by id.** Replacing a placeholder with final art (painted plate, cut-out layer, MP4 loop) only changes the asset map. +- **The world reacts to authoritative state, not to its own input,** so a clue found on a phone produces the same reaction on the TV. Reactions queue behind cinematics. + +--- + +## Audio system + +```mermaid +flowchart LR + subgraph Data["mystery-data"] + MAN["Audio manifests
(id → file, channel, volume, loop)"] + DIRN["Audio direction
music states, ambience per set, cues, moments"] + end + subgraph Logic["packages/scene"] + AD["AudioDirector
music state machine, crossfades,
ambient layers, voice ducking (music to 40%)"] + end + subgraph TVa["apps/tv/audio"] + AP["AudioProvider
place, events, settings"] + BE["w3cBackend
players, pooling, lifecycle counters"] + end + GSt["GameState + world place"] --> AP + MAN & DIRN --> AD + AP --> AD --> BE --> SPK["W3C media player"] + SET["Settings: master, music, effects, voice, subtitles"] --> AP +``` + +- No component names a file: everything is an id resolved through the manifest. +- Effective volume = master × channel × sound volume (× duck for music under voice). +- A missing file never blocks play: it is logged once (`[AUDIO_MISSING]` with id, reason and scene), voice lines keep their subtitles, music and ambience skip. +- A development trace (`[AUDIO_TRACE]`) logs every command and its cause; `apps/tv/scripts/trace-sink.js` collects it from the device. +- Jest journey tests drive the real provider, director and backend through a whole case and assert order, ducking, cleanup and player counts. + +--- + +## AI layer + +**Status:** suspect interrogation is live end to end: phone Question tab → server → guarded model reply → spoken on the TV with subtitles. AI hints and narration are next. Keys live only in `apps/server/.env` (git-ignored). The game remains fully playable without AI. + +### What the AI does +1. **Interrogation:** players pick a suspect on their phone and ask anything (typed, or spoken and transcribed). The suspect answers in character on the TV, with voice and subtitles. +2. **Adaptive hints:** the AI rephrases the next authored hint level for the group's situation. It cannot go beyond that level. +3. **Narration:** the narrator (detective, curator or Yaksha) reacts to discoveries and theories. + +### What the AI can never do +- Decide whether an answer is correct, unlock anything, or change score or state. Only the engine does. +- See a fact the room has not unlocked. Locked facts are never in the prompt. +- Say a guarded term early. Every reply is checked with `spoilersIn` before anyone sees it. +- Invent evidence. Replies are limited to speakable facts and clues already found. + +### Interrogation flow + +```mermaid +sequenceDiagram + autonumber + participant P as Phone + participant S as Server (GameSession) + participant G as Truth gates (engine) + participant A as AI gateway + participant M as Groq (free tier) + participant T as TV + P->>S: ASK_CHARACTER {characterId, question, evidenceClueId?} + S->>S: per-room budget, per-player cooldown, length cap + S->>T: CHARACTER_THINKING + S->>G: speakableFacts(character, state), activeGuardedTerms(state) + G-->>S: allowed facts, banned terms + S->>A: persona + allowed facts + found clues + question + A->>M: generate (short, in character) + alt provider error or limit + A->>M: next provider + end + M-->>A: reply + A->>G: spoilersIn(reply) + length + off-limits check + alt unsafe, or every provider failed + A-->>S: authored fallback line + else safe + A-->>S: reply + end + S->>T: CHARACTER_REPLY {characterId, text} + S->>P: CHARACTER_REPLY + S->>T: CHARACTER_VOICE {url} (Edge → Orpheus, fMP4 AAC) + T->>S: GET /voice/ (fetch → MediaSource) +``` + +### Cost and reliability +- Free tiers only, in `AI_ORDER` (default Groq `openai/gpt-oss-120b`, measured 0.5–1.2 s per line, then Gemini), then authored lines. Players never pay and never see a key. +- A per-room question budget (30 per playthrough), a per-player cooldown (4 s), short outputs, a 6 s timeout per provider and reply caching keep usage inside free limits. A rate-limited provider rests for a minute; a denied key rests for ten. +- `npx tsx apps/server/scripts/voice-samples.ts ` writes one sample per character to audition the casting. +- `npm run ai-smoke -w @roomriddle/server [groq|gemini]` asks real suspects with the real keys and prints which source answered. + +### Code map +| File | Role | +| --- | --- | +| `ai/prompt.ts` | Builds the prompt from persona, unlocked facts (minus any holding a live spoiler) and found clue titles. Player text is fenced. | +| `ai/providers.ts` | Gemini and Groq REST clients (key in headers, never in URLs). | +| `ai/guard.ts` | Strips stage directions, caps at three sentences, rejects spoilers, unsolved typed answers and out-of-character lines. | +| `ai/fallback.ts` | Authored replies from unlocked first-person facts, or a deflection in a tone suited to the case. | +| `ai/voice.ts` | Spoken replies: Edge neural voices (free, no key) via `msedge-tts`, content-addressed in-memory cache, `CHARACTER_VOICE` after the text so subtitles never wait. Each character's voice is data (`CharacterTruth.voice`). | +| `ai/CharacterService.ts` | Budget, cooldown, cache, provider chain, history (last three exchanges per character). Never changes game state. | + +### Server settings +| Variable | Default | Meaning | +| --- | --- | --- | +| `GROQ_API_KEY`, `GEMINI_API_KEY` | none | Free-tier keys, in `apps/server/.env` only. | +| `AI_ORDER` | `groq,gemini` | Provider order. | +| `GROQ_MODEL` / `GEMINI_MODEL` | `openai/gpt-oss-120b` / `gemini-3.8-flash` | Models. | +| `AI_DISABLED` | unset | `1` = authored lines only (offline demo). | +| `VOICES_DISABLED` | unset | `1` = subtitles only. | +| `AI_TIMEOUT_MS`, `AI_MAX_QUESTIONS`, `AI_COOLDOWN_MS` | 6000, 30, 4000 | Limits. | +- Voice: server-side free TTS, one fixed voice per character, subtitles always shown. Speech input via Groq Whisper from the phone microphone. + +--- + +## Expandable mystery library + +**Status: planned.** Downloaded code cannot safely be executed on the TV, so a mystery pack is **data only**: it describes a case using puzzle types, interactions and effects the installed engine already supports. + +```mermaid +flowchart LR + subgraph Authoring + AU["Author writes case
(TypeScript data)"] --> VAL["validateMysteryDefinition
+ asset check + playthrough test"] + VAL --> PK["Pack: manifest.json + assets
id, version, engineVersion, checksums"] + end + PK --> CDN["Object storage / CDN"] + PK --> CAT["Catalogue API + DB
(cases, versions, entitlements)"] + subgraph Device + LIB["Library screen"] --> CAT + LIB --> DL["Downloader
resume, checksum, engine compatibility"] + DL --> CACHE["Local pack cache"] + CACHE --> RUN["Engine + world + audio
(same runtime as built-in cases)"] + end + CDN --> DL + STORE["Amazon Appstore IAP"] --> CAT +``` + +- The server loads the same pack version as the TV before a room starts, so it stays authoritative. +- Packs declare a minimum engine version; incompatible packs are hidden, not crashed. +- Purchases are entitlements verified with the store, separate from downloaded files. + +--- + +## Security and privacy + +- Answer keys, hint text, clue descriptions and truth tables never reach a client before they are earned (`PublicMystery` + `GameReveals`). +- Every client frame is shape-validated; unknown fields are dropped; oversize frames are refused; per-connection rate limits apply. +- Session tokens are random 32 bytes, compared in constant time. +- No accounts and no personal data: a player is a nickname for the life of a room. Rooms live in memory. +- API keys are server-side only (`.env`, git-ignored). Questions go to the AI provider without names or identifiers. + +--- + +## Testing and quality + +| Suite | Runner | Covers | +|---|---|---| +| `packages/*`, `apps/server`, `apps/player` | `node:test` via `tsx` (`npm run check`) | Engine rules, scoring, validation, playable cases end to end, content validation, scene/camera/director/audio director, protocol, rooms, reconnection | +| `apps/tv` | Jest + React Native Testing Library (`wsl-check.sh`) | Navigation, screens, world, settings, speech bubble, full audio journey through the real game (75 tests) | +| Device | Vega Virtual Device scripts | `vvd-shot.sh` (remote keys + screenshots), `vvd-load.sh` (CPU), audio capture to WAV (`vvd-wav-start.sh`), audio trace sink | +| Lint / types | `tsc --noEmit`, ESLint | All workspaces | + +About 320 automated tests (244 package/server/phone + 75 TV) run before every commit. See `docs/testing.md`. + +--- + +## Performance + +Measured on the Vega Virtual Device (4 vCPU, software rendering), so these are upper bounds: + +| Situation | System CPU | +|---|---| +| Idle VVD | ~13% | +| Static screen, reduced motion | ~5% | +| World exploration | 56–62% | +| Arrival cinematic | ~75% | +| Memory | ~600 MB of 3.9 GB | + +Every animation uses the native driver and the frame loop runs only while something moves. Real hardware composites on the GPU and will be measured on a Fire TV Stick. diff --git a/docs/testing.md b/docs/testing.md index 69794d4..2fe7124 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -16,7 +16,24 @@ Test files sit next to the code as `*.test.ts`. - **Server:** room create/join, intent validation, broadcasts, reconnection, and making sure clients can't claim results. - **TV:** remote-only navigation walkthroughs on the Vega Virtual Device (manual checklist per milestone, automated where Vega tooling allows). -## Current coverage (Phase 6) +## Totals (2026-10-12) +| Suite | Tests | +|---|---| +| `game-engine` | 58 | +| `mystery-data` (validation + automatic playthrough of all five cases) | 22 | +| `scene` | 52 | +| `client` | 19 | +| `ui` | 4 | +| `server` (rooms, multiplayer, AI prompt/guard/fallback/service, voices, HTTP) | 78 | +| `player` | 11 | +| **Root `npm run check`** | **244** | +| TV, Jest (`apps/tv/scripts/wsl-check.sh`) | 75 | + +CI (`.github/workflows/ci.yml`) runs `npm run check` on every push to `main` and every pull request, with `AI_DISABLED=1` and `VOICES_DISABLED=1`, so no keys or network are needed. The AI tests use fake providers. The TV suite needs the Vega SDK and runs locally. + +The sections below record coverage as it grew, phase by phase. + +## Coverage at Phase 6 - **`game-engine` (55) and `mystery-data` (14):** the Phase 3 engine and The Last Train. - **`server` (43):** - `RoomManager.test.ts`: codes, join rules, name rules, limits, host-only start, the reconnect seat, token checks, lobby expiry, host pause/resume/timeout, and the action log.